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
122122 readonly variant?: "badge" | "panel";
123123 readonly onAction?: () => void;
124124 }>;
125− readonly signIn?: ComponentType<{
126− readonly sourceId: string;
127− }>;
128125 readonly presets?: readonly SourcePreset[];
129126 /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.).
130127 * 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
55 import { Button } from "@executor-js/react/components/button";
66
77 import { googleDiscoverySourceAtom } from "./atoms";
8+import GoogleDiscoverySignInButton from "./GoogleDiscoverySignInButton";
89
910 export default function EditGoogleDiscoverySource({
1011 sourceId,
@@ -63,9 +64,12 @@ export default function EditGoogleDiscoverySource({ · 52 unmodified lines
6364 <p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
6465 Authentication
6566 </p>
66− <p className="text-sm font-medium text-foreground capitalize">
67− {authKind === "oauth2" ? "OAuth 2.0" : authKind}
68− </p>
67+ <div className="flex min-h-9 items-center justify-between gap-3">
68+ <p className="text-sm font-medium text-foreground capitalize">
69+ {authKind === "oauth2" ? "OAuth 2.0" : authKind}
70+ </p>
71+ {authKind === "oauth2" && <GoogleDiscoverySignInButton sourceId={sourceId} />}
72+ </div>
6973 </div>
7074 </div>
7175 )}
packages/plugins/google-discovery/src/react/source-plugin.ts+0−3
@@ -5,7 +5,6 @@ import { googleDiscoveryPresets } from "../sdk/presets"; · 4 unmodified lines
55 const importAdd = () => import("./AddGoogleDiscoverySource");
66 const importEdit = () => import("./EditGoogleDiscoverySource");
77 const importSummary = () => import("./GoogleDiscoverySourceSummary");
8−const importSignIn = () => import("./GoogleDiscoverySignInButton");
98
109 export const googleDiscoverySourcePlugin: SourcePlugin = {
1110 key: "googleDiscovery",
@@ -13,12 +12,10 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 1 unmodified line
1312 add: lazy(importAdd),
1413 edit: lazy(importEdit),
1514 summary: lazy(importSummary),
16− signIn: lazy(importSignIn),
1715 presets: googleDiscoveryPresets,
1816 preload: () => {
1917 void importAdd();
2018 void importEdit();
2119 void importSummary();
22− void importSignIn();
2320 },
2421 };
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−23
@@ -16,7 +16,6 @@ import { · 15 unmodified lines
1616 import {
1717 sourceDisplayNameFromUrl,
1818 slugifyNamespace,
19− SourceIdentityFieldRows,
2019 useSourceIdentity,
2120 } from "@executor-js/react/plugins/source-identity";
2221 import {
@@ -33,16 +32,11 @@ import { · 10 unmodified lines
3332 import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
3433 import { Button } from "@executor-js/react/components/button";
3534 import { FilterTabs } from "@executor-js/react/components/filter-tabs";
36−import {
37− CardStack,
38− CardStackContent,
39− CardStackEntryField,
40−} from "@executor-js/react/components/card-stack";
4135 import { FloatActions } from "@executor-js/react/components/float-actions";
42−import { Input } from "@executor-js/react/components/input";
4336 import { Spinner } from "@executor-js/react/components/spinner";
4437 import { addGraphqlSourceOptimistic } from "./atoms";
4538 import { initialGraphqlCredentials } from "./defaults";
39+import { GraphqlSourceFields } from "./GraphqlSourceFields";
4640 import type { GraphqlCredentialInput } from "../sdk/types";
4741
4842 const ErrorMessage = Schema.Struct({ message: Schema.String });
@@ -181,22 +175,7 @@ export default function AddGraphqlSource(props: { · 132 unmodified lines
181175 <div className="flex flex-1 flex-col gap-6">
182176 <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1>
183177
184− <CardStack>
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>
178+ <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} />
200179
201180 <HttpCredentialsEditor
202181 credentials={credentials}
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+83−47
@@ -2,40 +2,39 @@ import { useState } from "react"; · 1 unmodified line
22 import { useAtomValue, useAtomSet } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import { graphqlSourceAtom, graphqlSourceBindingsAtom, updateGraphqlSource } from "./atoms";
6−import { useScope } from "@executor-js/react/api/scope-context";
7−import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
5+import {
6+ graphqlSourceAtom,
7+ graphqlSourceBindingsAtom,
8+ setGraphqlSourceBinding,
9+ updateGraphqlSource,
10+} from "./atoms";
11+import { connectionsAtom } from "@executor-js/react/api/atoms";
12+import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
13+import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
814 import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
915 import {
1016 HttpCredentialsEditor,
17+ serializeHttpCredentials,
1118 serializeScopedHttpCredentials,
1219 type HttpCredentialsState,
1320 } from "@executor-js/react/plugins/http-credentials";
1421 import {
22+ effectiveCredentialBindingForScope,
1523 httpCredentialsFromConfiguredCredentialBindings,
1624 initialCredentialTargetScope,
1725 } from "@executor-js/react/plugins/credential-bindings";
18−import {
19− SourceIdentityFields,
20− useSourceIdentity,
21−} from "@executor-js/react/plugins/source-identity";
22−import {
23− CredentialTargetScopeSelector,
24− useCredentialTargetScope,
25−} from "@executor-js/react/plugins/credential-target-scope";
26+import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity";
27+import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope";
2628 import { Button } from "@executor-js/react/components/button";
2729 import { FilterTabs } from "@executor-js/react/components/filter-tabs";
28−import {
29− CardStack,
30− CardStackContent,
31− CardStackEntryField,
32−} from "@executor-js/react/components/card-stack";
33−import { Input } from "@executor-js/react/components/input";
30+import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection";
3431 import { Badge } from "@executor-js/react/components/badge";
3532 import { ScopeId } from "@executor-js/sdk/core";
33+import { GraphqlSourceFields } from "./GraphqlSourceFields";
3634 import {
3735 GRAPHQL_OAUTH_CONNECTION_SLOT,
3836 type GraphqlCredentialInput,
37+ GraphqlSourceBindingInput,
3938 type GraphqlSourceBindingRef,
4039 } from "../sdk/types";
4140 import type { StoredGraphqlSource } from "../sdk/store";
@@ -54,14 +53,23 @@ function EditForm(props: { · 12 unmodified lines
5453 onSave: () => void;
5554 }) {
5655 const displayScope = useScope();
56+ const scopeStack = useScopeStack();
5757 const sourceScope = ScopeId.make(props.initial.scope);
58− const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
59− useCredentialTargetScope({
60− sourceScope,
61− initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
62− });
58+ const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({
59+ sourceScope,
60+ initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
61+ });
62+ const {
63+ credentialTargetScope: oauthCredentialTargetScope,
64+ setCredentialTargetScope: setOAuthCredentialTargetScope,
65+ } = useCredentialTargetScope({
66+ sourceScope,
67+ initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
68+ });
6369 const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" });
70+ const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" });
6471 const secretList = useSecretPickerSecrets();
72+ const connectionsResult = useAtomValue(connectionsAtom(displayScope));
6573
6674 const identity = useSourceIdentity({
6775 fallbackName: props.initial.name,
@@ -84,6 +92,23 @@ function EditForm(props: { · 16 unmodified lines
8492 const identityDirty = identity.name.trim() !== props.initial.name.trim();
8593 const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim();
8694 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);
87112
88113 const handleCredentialsChange = (next: HttpCredentialsState) => {
89114 setCredentials(next);
@@ -164,31 +189,11 @@ function EditForm(props: { · 74 unmodified lines
164189 </Badge>
165190 </div>
166191
167− <SourceIdentityFields identity={identity} namespaceReadOnly />
168−
169− <CardStack>
170− <CardStackContent className="border-t-0">
171− <CardStackEntryField label="Endpoint">
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+ <GraphqlSourceFields
193+ endpoint={endpoint}
194+ onEndpointChange={setEndpoint}
195+ identity={identity}
196+ namespaceReadOnly
192197 />
193198
194199 <HttpCredentialsEditor
@@ -224,6 +229,37 @@ function EditForm(props: { · 29 unmodified lines
224229 )}
225230 </section>
226231
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+
227263 {error && (
228264 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
229265 <p className="text-sm text-destructive">{error}</p>
packages/plugins/graphql/src/react/GraphqlSourceFields.tsx+42−0
@@ -0,0 +1,42 @@
1+import {
2+ CardStack,
3+ CardStackContent,
4+ CardStackEntryField,
5+} from "@executor-js/react/components/card-stack";
6+import { Input } from "@executor-js/react/components/input";
7+import {
8+ SourceIdentityFieldRows,
9+ type SourceIdentity,
10+} from "@executor-js/react/plugins/source-identity";
11+
12+export 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 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3+
4+import { connectionsAtom } from "@executor-js/react/api/atoms";
5+import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";
6+import {
7+ SourceCredentialNotice,
8+ SourceCredentialStatusBadge,
9+ missingSourceCredentialLabels,
10+ type SourceCredentialSlot,
11+} from "@executor-js/react/plugins/source-credential-status";
12+import { ScopeId } from "@executor-js/sdk/core";
13+
14+import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms";
15+import type { StoredGraphqlSource } from "../sdk/store";
16+
17+const 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+
135 export default function GraphqlSourceSummary(props: {
236 sourceId: string;
337 variant?: "badge" | "panel";
38+ onAction?: () => void;
439 }) {
5− if (props.variant === "panel") return null;
6− return <span>GraphQL · {props.sourceId}</span>;
40+ const displayScope = useScope();
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} />;
776 }
packages/plugins/graphql/src/react/source-plugin.ts+0−3
@@ -5,7 +5,6 @@ import { graphqlPresets } from "../sdk/presets"; · 4 unmodified lines
55 const importAdd = () => import("./AddGraphqlSource");
66 const importEdit = () => import("./EditGraphqlSource");
77 const importSummary = () => import("./GraphqlSourceSummary");
8−const importSignIn = () => import("./GraphqlSignInButton");
98
109 export const graphqlSourcePlugin: SourcePlugin = {
1110 key: "graphql",
@@ -13,12 +12,10 @@ export const graphqlSourcePlugin: SourcePlugin = { · 1 unmodified line
1312 add: lazy(importAdd),
1413 edit: lazy(importEdit),
1514 summary: lazy(importSummary),
16− signIn: lazy(importSignIn),
1715 presets: graphqlPresets,
1816 preload: () => {
1917 void importAdd();
2018 void importEdit();
2119 void importSummary();
22− void importSignIn();
2320 },
2421 };
packages/plugins/mcp/src/react/AddMcpSource.tsx+12−121
@@ -10,22 +10,14 @@ import { · 9 unmodified lines
1010 CardStack,
1111 CardStackContent,
1212 CardStackEntry,
13− CardStackEntryActions,
14− CardStackEntryContent,
15− CardStackEntryDescription,
1613 CardStackEntryField,
17− CardStackEntryMedia,
18− CardStackEntryTitle,
1914 } from "@executor-js/react/components/card-stack";
20−import { FieldError, FieldLabel } from "@executor-js/react/components/field";
15+import { FieldLabel } from "@executor-js/react/components/field";
2116 import { FilterTabs } from "@executor-js/react/components/filter-tabs";
2217 import { FloatActions } from "@executor-js/react/components/float-actions";
2318 import { Input } from "@executor-js/react/components/input";
2419 import { Label } from "@executor-js/react/components/label";
25−import { Badge } from "@executor-js/react/components/badge";
26−import { Skeleton } from "@executor-js/react/components/skeleton";
27−import { SourceFavicon } from "@executor-js/react/components/source-favicon";
28−import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
20+import { Spinner } from "@executor-js/react/components/spinner";
2921 import { Textarea } from "@executor-js/react/components/textarea";
3022 import {
3123 emptyHttpCredentials,
@@ -37,7 +29,6 @@ import { · 5 unmodified lines
3729 import {
3830 sourceDisplayNameFromUrl,
3931 slugifyNamespace,
40− SourceIdentityFieldRows,
4132 SourceIdentityFields,
4233 useSourceIdentity,
4334 } from "@executor-js/react/plugins/source-identity";
@@ -57,6 +48,7 @@ import { · 13 unmodified lines
5748 type RemoteAuthMode = "none" | "oauth2";
5849 import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
5950 import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";
51+import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
6052 import { mcpPresets, type McpPreset } from "../sdk/presets";
6153 import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";
6254
@@ -599,116 +591,15 @@ export default function AddMcpSource(props: { · 536 unmodified lines
599591
600592 {transport === "remote" ? (
601593 <>
602− {/* Server info card (shown above URL input after probing) */}
603− {probe ? (
604− <CardStack>
605− <CardStackContent className="border-t-0">
606− <CardStackEntry>
607− <CardStackEntryMedia>
608− <SourceFavicon url={state.url} size={32} />
609− </CardStackEntryMedia>
610− <CardStackEntryContent>
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− )}
594+ <McpRemoteSourceFields
595+ url={state.url}
596+ onUrlChange={(url) => dispatch({ type: "set-url", url })}
597+ identity={remoteIdentity}
598+ preview={probe}
599+ probing={isProbing}
600+ error={probeError}
601+ onRetry={handleProbe}
602+ />
712603
713604 <HttpCredentialsEditor
714605 credentials={remoteCredentials}
packages/plugins/mcp/src/react/EditMcpSource.tsx+93−47
@@ -2,37 +2,39 @@ import { useState } from "react"; · 1 unmodified line
22 import { useAtomValue, useAtomSet } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44 import * as Exit from "effect/Exit";
5−import { mcpSourceAtom, mcpSourceBindingsAtom, updateMcpSource } from "./atoms";
6−import { useScope } from "@executor-js/react/api/scope-context";
7−import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
85 import {
9− SourceIdentityFields,
10− useSourceIdentity,
11−} from "@executor-js/react/plugins/source-identity";
12−import {
13− CredentialTargetScopeSelector,
14− useCredentialTargetScope,
15−} from "@executor-js/react/plugins/credential-target-scope";
6+ mcpSourceAtom,
7+ mcpSourceBindingsAtom,
8+ setMcpSourceBinding,
9+ updateMcpSource,
10+} from "./atoms";
11+import { connectionsAtom } from "@executor-js/react/api/atoms";
12+import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
13+import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
14+import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity";
15+import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope";
1616 import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
1717 import {
1818 HttpCredentialsEditor,
19+ serializeHttpCredentials,
1920 serializeScopedHttpCredentials,
2021 type HttpCredentialsState,
2122 } from "@executor-js/react/plugins/http-credentials";
2223 import {
24+ effectiveCredentialBindingForScope,
2325 httpCredentialsFromConfiguredCredentialBindings,
2426 initialCredentialTargetScope,
2527 } from "@executor-js/react/plugins/credential-bindings";
28+import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection";
2629 import { Button } from "@executor-js/react/components/button";
27−import {
28− CardStack,
29− CardStackContent,
30− CardStackEntryField,
31−} from "@executor-js/react/components/card-stack";
32−import { Input } from "@executor-js/react/components/input";
3330 import { Badge } from "@executor-js/react/components/badge";
3431 import { ScopeId } from "@executor-js/sdk/core";
35−import type { McpCredentialInput, McpSourceBindingRef } from "../sdk/types";
32+import { McpRemoteSourceFields } from "./McpRemoteSourceFields";
33+import {
34+ McpSourceBindingInput,
35+ type McpCredentialInput,
36+ type McpSourceBindingRef,
37+} from "../sdk/types";
3638 import type { McpStoredSourceSchemaType } from "../sdk/stored-source";
3739
3840 // ---------------------------------------------------------------------------
@@ -46,14 +48,23 @@ function RemoteEditForm(props: { · 7 unmodified lines
4648 onSave: () => void;
4749 }) {
4850 const displayScope = useScope();
51+ const scopeStack = useScopeStack();
4952 const sourceScope = ScopeId.make(props.initial.scope);
50− const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
51− useCredentialTargetScope({
52− sourceScope,
53− initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
54− });
53+ const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({
54+ sourceScope,
55+ initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
56+ });
57+ const {
58+ credentialTargetScope: oauthCredentialTargetScope,
59+ setCredentialTargetScope: setOAuthCredentialTargetScope,
60+ } = useCredentialTargetScope({
61+ sourceScope,
62+ initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings),
63+ });
5564 const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" });
65+ const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" });
5666 const secretList = useSecretPickerSecrets();
67+ const connectionsResult = useAtomValue(connectionsAtom(displayScope));
5768
5869 const identity = useSourceIdentity({
5970 fallbackName: props.initial.name,
@@ -74,6 +85,23 @@ function RemoteEditForm(props: { · 14 unmodified lines
7485 const identityDirty = identity.name.trim() !== props.initial.name.trim();
7586 const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim();
7687 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);
77105
78106 const handleCredentialsChange = (next: HttpCredentialsState) => {
79107 setCredentials(next);
@@ -137,32 +165,17 @@ function RemoteEditForm(props: { · 57 unmodified lines
137165 </Badge>
138166 </div>
139167
140− <SourceIdentityFields identity={identity} namespaceReadOnly />
141−
142− {/* Endpoint */}
143− <CardStack>
144− <CardStackContent className="border-t-0">
145− <CardStackEntryField label="Endpoint">
146− <Input
147− value={endpoint}
148− onChange={(e) => {
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);
168+ <McpRemoteSourceFields
169+ url={endpoint}
170+ onUrlChange={setEndpoint}
171+ identity={identity}
172+ preview={{
173+ name: props.initial.name,
174+ serverName: props.initial.name,
175+ connected: true,
176+ toolCount: null,
164177 }}
165− description="Choose where updated MCP credentials are saved."
178+ namespaceReadOnly
166179 />
167180
168181 <HttpCredentialsEditor
@@ -175,6 +188,39 @@ function RemoteEditForm(props: { · 6 unmodified lines
175188 bindingScopeOptions={credentialScopeOptions}
176189 />
177190
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+
178224 {error && (
179225 <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
180226 <p className="text-sm text-destructive">{error}</p>
packages/plugins/mcp/src/react/McpRemoteSourceFields.tsx+163−0
@@ -0,0 +1,163 @@
1+import { Badge } from "@executor-js/react/components/badge";
2+import {
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";
13+import { FieldError } from "@executor-js/react/components/field";
14+import { Input } from "@executor-js/react/components/input";
15+import { Skeleton } from "@executor-js/react/components/skeleton";
16+import { SourceFavicon } from "@executor-js/react/components/source-favicon";
17+import { IOSSpinner } from "@executor-js/react/components/spinner";
18+import { Button } from "@executor-js/react/components/button";
19+import {
20+ SourceIdentityFieldRows,
21+ type SourceIdentity,
22+} from "@executor-js/react/plugins/source-identity";
23+
24+export type McpRemoteSourcePreview = {
25+ readonly name: string;
26+ readonly serverName: string | null;
27+ readonly connected: boolean;
28+ readonly toolCount: number | null;
29+};
30+
31+export 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 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3+
4+import { connectionsAtom } from "@executor-js/react/api/atoms";
5+import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";
6+import {
7+ SourceCredentialNotice,
8+ SourceCredentialStatusBadge,
9+ missingSourceCredentialLabels,
10+ type SourceCredentialSlot,
11+} from "@executor-js/react/plugins/source-credential-status";
12+import { ScopeId } from "@executor-js/sdk/core";
13+
14+import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms";
15+import type { McpStoredSourceSchemaType } from "../sdk/stored-source";
16+
17+const 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+
52+export 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
44
55 const importAdd = () => import("./AddMcpSource");
66 const importEdit = () => import("./EditMcpSource");
7−const importSignIn = () => import("./McpSignInButton");
7+const importSummary = () => import("./McpSourceSummary");
88
99 const LazyAddMcpSource = lazy(importAdd);
1010 const LazyEditMcpSource = lazy(importEdit);
11−const LazyMcpSignInButton = lazy(importSignIn);
11+const LazyMcpSourceSummary = lazy(importSummary);
1212
1313 type AddProps = ComponentProps<SourcePlugin["add"]>;
1414
@@ -41,12 +41,12 @@ export const createMcpSourcePlugin = (options?: McpSourcePluginOptions): SourceP · 26 unmodified lines
4141 label: "MCP",
4242 add: AddWithFlag,
4343 edit: LazyEditMcpSource,
44− signIn: LazyMcpSignInButton,
44+ summary: LazyMcpSourceSummary,
4545 presets,
4646 preload: () => {
4747 void importAdd();
4848 void importEdit();
49− void importSignIn();
49+ void importSummary();
5050 },
5151 };
5252 };
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+28−67
@@ -34,11 +34,7 @@ import { · 33 unmodified lines
3434 type HeaderState,
3535 } from "@executor-js/react/plugins/secret-header-auth";
3636 import { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope";
37−import {
38− slugifyNamespace,
39− SourceIdentityFieldRows,
40− useSourceIdentity,
41−} from "@executor-js/react/plugins/source-identity";
37+import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity";
4238 import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
4339 import { Button } from "@executor-js/react/components/button";
4440 import { CopyButton } from "@executor-js/react/components/copy-button";
@@ -47,27 +43,21 @@ import { · 2 unmodified lines
4743 CollapsibleContent,
4844 CollapsibleTrigger,
4945 } from "@executor-js/react/components/collapsible";
50−import { FreeformCombobox } from "@executor-js/react/components/combobox";
5146 import {
5247 CardStack,
5348 CardStackContent,
54− CardStackEntry,
55− CardStackEntryContent,
56− CardStackEntryDescription,
5749 CardStackEntryField,
58− CardStackEntryTitle,
5950 } from "@executor-js/react/components/card-stack";
6051 import { FieldLabel } from "@executor-js/react/components/field";
6152 import { FloatActions } from "@executor-js/react/components/float-actions";
6253 import { HelpTooltip } from "@executor-js/react/components/help-tooltip";
63−import { Input } from "@executor-js/react/components/input";
6454 import { Label } from "@executor-js/react/components/label";
6555 import { Textarea } from "@executor-js/react/components/textarea";
6656 import { Checkbox } from "@executor-js/react/components/checkbox";
67−import { SourceFavicon } from "@executor-js/react/components/source-favicon";
6857 import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
6958 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
7059 import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
60+import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
7161 import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
7262 import {
7363 headerBindingSlot,
@@ -936,61 +926,32 @@ export default function AddOpenApiSource(props: { · 862 unmodified lines
936926
937927 {/* ── Source information card (shown after analysis) ── */}
938928 {preview ? (
939− <CardStack>
940− <CardStackContent className="border-t-0">
941− <CardStackEntry>
942− {resolvedBaseUrl && <SourceFavicon url={resolvedBaseUrl} size={16} />}
943− <CardStackEntryContent>
944− <CardStackEntryTitle>
945− {Option.getOrElse(preview.title, () => "API")}
946− </CardStackEntryTitle>
947− <CardStackEntryDescription>
948− {Option.getOrElse(preview.version, () => "")}
949− {Option.isSome(preview.version) && " · "}
950− {preview.operationCount} operation
951− {preview.operationCount !== 1 ? "s" : ""}
952− {preview.tags.length > 0 &&
953− ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`}
954− </CardStackEntryDescription>
955− </CardStackEntryContent>
956− </CardStackEntry>
957− <SourceIdentityFieldRows identity={identity} />
958− <div className="grid grid-cols-1 md:grid-cols-2">
959− <CardStackEntryField label="Base URL">
960− <FreeformCombobox
961− value={resolvedBaseUrl}
962− onValueChange={setBaseUrl}
963− options={baseUrlOptions}
964− placeholder="https://api.example.com"
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>
929+ <OpenApiSourceDetailsFields
930+ title={Option.getOrElse(preview.title, () => "API")}
931+ description={`${Option.getOrElse(preview.version, () => "")}${
932+ Option.isSome(preview.version) ? " · " : ""
933+ }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${
934+ preview.tags.length > 0
935+ ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`
936+ : ""
937+ }`}
938+ identity={identity}
939+ baseUrl={resolvedBaseUrl}
940+ onBaseUrlChange={setBaseUrl}
941+ baseUrlOptions={baseUrlOptions}
942+ specUrl={specUrl}
943+ onSpecUrlChange={(value) => {
944+ setSpecUrl(value);
945+ setPreview(null);
946+ setBaseUrl("");
947+ setCustomHeaders([]);
948+ setStrategy({ kind: "none" });
949+ setOauth2AuthState(null);
950+ setOauth2Error(null);
951+ }}
952+ faviconUrl={resolvedBaseUrl}
953+ baseUrlMissingMessage="A base URL is required to make requests."
954+ />
994955 ) : null}
995956
996957 {analyzeError && (
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+32−41
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
4444 setOpenApiSourceBinding,
4545 updateOpenApiSource,
4646 } from "./atoms";
47+import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
4748 import {
4849 OPENAPI_OAUTH_CALLBACK_PATH,
4950 OPENAPI_OAUTH_POPUP_NAME,
@@ -178,6 +179,16 @@ export default function EditOpenApiSource(props: { · 128 unmodified lines
178179 const [oauth2EndpointsSaveState, setOAuth2EndpointsSaveState] = useState<
179180 "idle" | "saving" | "saved"
180181 >("idle");
182+ const editIdentity = useMemo(
183+ () => ({
184+ name,
185+ namespace: props.sourceId,
186+ setName,
187+ setNamespace: () => {},
188+ reset: () => {},
189+ }),
190+ [name, props.sourceId],
191+ );
181192 const sourceSaveSeq = useRef(0);
182193 const oauth2EndpointsSaveSeq = useRef(0);
183194
@@ -580,47 +591,27 @@ export default function EditOpenApiSource(props: { · 396 unmodified lines
580591 <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1>
581592 </div>
582593
583− <CardStack>
584− <CardStackContent className="border-t-0">
585− <CardStackEntry>
586− <CardStackEntryContent>
587− <CardStackEntryTitle>Source Details</CardStackEntryTitle>
588− <CardStackEntryDescription>
589− Name and base URL save automatically.
590− </CardStackEntryDescription>
591− </CardStackEntryContent>
592− {sourceSaveState !== "idle" && (
593− <span className="text-xs text-muted-foreground">
594− {sourceSaveState === "saving" ? "Saving…" : "Saved"}
595− </span>
596− )}
597− </CardStackEntry>
598− <CardStackEntryField label="Name">
599− <Input value={name} onChange={(e) => setName((e.target as HTMLInputElement).value)} />
600− </CardStackEntryField>
601− <CardStackEntryField label="Base URL">
602− <Input
603− value={baseUrl}
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>
594+ <OpenApiSourceDetailsFields
595+ title="Source Details"
596+ description="Name and base URL save automatically."
597+ identity={editIdentity}
598+ baseUrl={baseUrl}
599+ onBaseUrlChange={setBaseUrl}
600+ specUrl={source.config.sourceUrl ?? ""}
601+ onSpecUrlChange={() => {}}
602+ specUrlDisabled
603+ namespaceReadOnly
604+ saveState={sourceSaveState}
605+ footer={
606+ source.config.oauth2
607+ ? `Authentication Template: OAuth2 ${source.config.oauth2.flow}`
608+ : Object.keys(source.config.headers ?? {}).length > 0
609+ ? `Authentication Template: ${Object.keys(source.config.headers ?? {}).length} header binding${
610+ Object.keys(source.config.headers ?? {}).length === 1 ? "" : "s"
611+ }`
612+ : "Authentication Template: None"
613+ }
614+ />
624615
625616 <CardStack>
626617 <CardStackContent className="border-t-0">
packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx+108−0
@@ -0,0 +1,108 @@
1+import {
2+ CardStack,
3+ CardStackContent,
4+ CardStackEntry,
5+ CardStackEntryContent,
6+ CardStackEntryDescription,
7+ CardStackEntryField,
8+ CardStackEntryTitle,
9+} from "@executor-js/react/components/card-stack";
10+import {
11+ FreeformCombobox,
12+ type FreeformComboboxOption,
13+} from "@executor-js/react/components/combobox";
14+import { Input } from "@executor-js/react/components/input";
15+import { SourceFavicon } from "@executor-js/react/components/source-favicon";
16+import {
17+ SourceIdentityFieldRows,
18+ type SourceIdentity,
19+} from "@executor-js/react/plugins/source-identity";
20+
21+export 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
33
44 import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms";
55 import { Badge } from "@executor-js/react/components/badge";
6−import { Button } from "@executor-js/react/components/button";
76 import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";
87 import { ScopeId } from "@executor-js/sdk/core";
8+import {
9+ SourceCredentialNotice,
10+ SourceCredentialStatusBadge,
11+ missingSourceCredentialLabels,
12+ type SourceCredentialSlot,
13+} from "@executor-js/react/plugins/source-credential-status";
914
1015 import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms";
11−import { effectiveBindingForScope, missingCredentialLabels } from "../sdk/credential-status";
12−
13−function 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−}
16+import { effectiveBindingForScope } from "../sdk/credential-status";
17+import { oauth2ClientSecretSlot, type StoredSourceSchemaType } from "../sdk/store";
2318
2419 function OAuthBadge() {
2520 return <Badge variant="secondary">OAuth</Badge>;
2621 }
2722
28−function 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−
3923 function CheckingCredentialsBadge() {
4024 return (
4125 <Badge
@@ -47,6 +31,36 @@ function CheckingCredentialsBadge() { · 5 unmodified lines
4731 );
4832 }
4933
34+const effectiveClientSecretSlot = (oauth2: {
35+ readonly securitySchemeName: string;
36+ readonly clientSecretSlot: string | null;
37+}): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName);
38+
39+const 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+
5064 // The entry row already renders name + id + kind, so this summary
5165 // component only contributes extras — specifically, an OAuth status
5266 // badge when the source has OAuth2 configured. Non-OAuth sources
@@ -89,34 +103,19 @@ export default function OpenApiSourceSummary(props: { · 36 unmodified lines
89103 const liveConnectionIds = new Set(connections.map((connection) => connection.id));
90104 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));
91105 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,
93111 liveConnectionIds,
94112 });
95113
96114 if (props.variant === "panel") {
97− if (missing.length === 0) return null;
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− );
115+ return <SourceCredentialNotice missing={missing} onAction={props.onAction} />;
117116 }
118117
119− if (missing.length > 0) return <NeedsCredentialsBadge />;
118+ if (missing.length > 0) return <SourceCredentialStatusBadge missing={missing} />;
120119
121120 if (!oauth2) return null;
122121 const connectionBinding = effectiveBindingForScope(
@@ -131,7 +130,7 @@ export default function OpenApiSourceSummary(props: { · 8 unmodified lines
131130 : null;
132131
133132 if (connectionId && connections.some((connection) => connection.id === connectionId)) {
134− return <ConnectedBadge />;
133+ return <SourceCredentialStatusBadge missing={[]} />;
135134 }
136135
137136 return <OAuthBadge />;
packages/react/src/pages/source-detail.tsx+0−6
@@ -166,12 +166,6 @@ export function SourceDetailPage(props: { namespace: string }) { · 165 unmodified lines
166166 </div>
167167
168168 <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−
175169 {canEdit && editPlugin && !editing && !confirmDelete && (
176170 <Button variant="outline" size="sm" onClick={() => setEditing(true)}>
177171 Edit
packages/react/src/plugins/credential-target-scope.tsx+8−3
@@ -170,12 +170,15 @@ export function CredentialScopeDropdown(props: { · 169 unmodified lines
170170 readonly onChange: (scope: ScopeId) => void;
171171 readonly label?: string;
172172 readonly help?: ReactNode;
173+ readonly className?: string;
174+ readonly triggerClassName?: string;
175+ readonly size?: "sm" | "default";
173176 }) {
174177 const label = props.label ?? "Used by";
175178 if (props.options.length <= 1) return null;
176179
177180 return (
178− <div className="space-y-1.5">
181+ <div className={props.className ?? "space-y-1.5"}>
179182 <div className="flex items-center gap-1.5">
180183 <FieldLabel className="text-[11px]">{label}</FieldLabel>
181184 <HelpTooltip label={label}>
@@ -186,7 +189,7 @@ export function CredentialScopeDropdown(props: { · 4 unmodified lines
186189 value={String(props.value)}
187190 onValueChange={(value) => props.onChange(ScopeId.make(value))}
188191 >
189− <SelectTrigger className="w-full">
192+ <SelectTrigger className={props.triggerClassName ?? "w-full"} size={props.size}>
190193 <SelectValue placeholder={label} />
191194 </SelectTrigger>
192195 <SelectContent>
@@ -230,7 +233,7 @@ export function CredentialUsageRow(props: { · 37 unmodified lines
230233 }
231234
232235 return (
233− <div className="grid gap-2 md:grid-cols-2">
236+ <div className="grid items-stretch gap-2 md:grid-cols-2">
234237 <div className="min-w-0 space-y-2.5">{props.children}</div>
235238 <CredentialScopeDropdown
236239 value={props.value}
@@ -238,6 +241,8 @@ export function CredentialUsageRow(props: { · 1 unmodified line
238241 onChange={props.onChange}
239242 label={props.label}
240243 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"
241246 />
242247 </div>
243248 );
packages/react/src/plugins/source-credential-status-core.ts+78−0
@@ -0,0 +1,78 @@
1+import type { ConnectionId, CredentialBindingValue, ScopeId } from "@executor-js/sdk";
2+
3+export 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+
17+export type SourceCredentialBindingRef = {
18+ readonly slot: string;
19+ readonly scopeId: ScopeId;
20+ readonly value: CredentialBindingValue;
21+};
22+
23+const scopeRank = (ranks: ReadonlyMap<string, number>, scopeId: ScopeId): number =>
24+ ranks.get(scopeId) ?? Number.MAX_SAFE_INTEGER;
25+
26+export 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+
38+const liveConnectionSet = (
39+ values?: ReadonlySet<string> | readonly ConnectionId[],
40+): ReadonlySet<string> | undefined => (values ? new Set(Array.from(values, String)) : undefined);
41+
42+export 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk";
3+
4+import {
5+ effectiveSourceCredentialBinding,
6+ missingSourceCredentialLabels,
7+ type SourceCredentialBindingRef,
8+ type SourceCredentialSlot,
9+} from "./source-credential-status-core";
10+
11+const userScope = ScopeId.make("user");
12+const orgScope = ScopeId.make("org");
13+const scopeRanks = new Map([
14+ [userScope, 0],
15+ [orgScope, 1],
16+]);
17+
18+const slots: readonly SourceCredentialSlot[] = [
19+ { kind: "secret", slot: "header:authorization", label: "Authorization" },
20+ { kind: "connection", slot: "auth:oauth2:connection", label: "OAuth sign-in" },
21+];
22+
23+const 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+
42+describe("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 @@
1+import { Badge } from "../components/badge";
2+import { Button } from "../components/button";
3+export {
4+ effectiveSourceCredentialBinding,
5+ missingSourceCredentialLabels,
6+ type SourceCredentialBindingRef,
7+ type SourceCredentialSlot,
8+} from "./source-credential-status-core";
9+
10+export 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+
32+export 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 @@
1+import type { ConnectionId, ScopeId, SecretBackedValue } from "@executor-js/sdk";
2+
3+import {
4+ CredentialControlField,
5+ CredentialUsageRow,
6+ type CredentialTargetScopeOption,
7+} from "./credential-target-scope";
8+import { SourceOAuthSignInButton } from "./oauth-sign-in";
9+
10+export 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+}