History

rework source add screens

Rhys Sullivan committed 6 months ago

Commit cc8ce0b1 parent 04028fd9 files · +546 −492Browse files

Unified · Split

9 files
  1. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +19 −17
  2. packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx +5 −1
  3. packages/plugins/mcp/src/react/AddMcpSource.tsx +83 −65
  4. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +280 −382
  5. packages/plugins/openapi/src/sdk/preview-oauth2.test.ts +9 −2
  6. packages/plugins/openapi/src/sdk/preview.ts +16 −2
  7. packages/react/src/components/combobox.tsx +65 −1
  8. packages/react/src/plugins/credential-target-scope.tsx +23 −0
  9. packages/react/src/plugins/source-identity.tsx +46 −22
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+19−17
@@ -15,7 +15,7 @@ import { · 14 unmodified lines
1515 import {
1616 displayNameFromUrl,
1717 slugifyNamespace,
18− SourceIdentityFields,
18+ SourceIdentityFieldRows,
1919 useSourceIdentity,
2020 } from "@executor-js/react/plugins/source-identity";
2121 import {
@@ -25,7 +25,7 @@ import { · 3 unmodified lines
2525 type OAuthCompletionPayload,
2626 } from "@executor-js/react/plugins/oauth-sign-in";
2727 import {
28− CredentialTargetScopeSelector,
28+ CredentialScopeSection,
2929 useCredentialTargetScope,
3030 } from "@executor-js/react/plugins/credential-target-scope";
3131 import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
@@ -72,7 +72,9 @@ export default function AddGraphqlSource(props: { · 40 unmodified lines
7272 const scopeId = useScope();
7373 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
7474 useCredentialTargetScope();
75− const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit" });
75+ const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), {
76+ mode: "promiseExit",
77+ });
7678 const secretList = useSecretPickerSecrets();
7779 const oauth = useOAuthPopupFlow({
7880 popupName: "graphql-oauth",
@@ -138,7 +140,9 @@ export default function AddGraphqlSource(props: { · 59 unmodified lines
138140 namespace,
139141 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),
140142 ...(Object.keys(queryParams).length > 0
141− ? { queryParams: queryParams as Record<string, GraphqlCredentialInput> }
143+ ? {
144+ queryParams: queryParams as Record<string, GraphqlCredentialInput>,
145+ }
142146 : {}),
143147 credentialTargetScope,
144148 ...(authMode === "oauth2" && tokens
@@ -177,28 +181,26 @@ export default function AddGraphqlSource(props: { · 32 unmodified lines
177181 className="font-mono text-sm"
178182 />
179183 </CardStackEntryField>
184+ <SourceIdentityFieldRows identity={identity} namePlaceholder="e.g. Shopify API" />
180185 </CardStackContent>
181186 </CardStack>
182187
183− <SourceIdentityFields identity={identity} namePlaceholder="e.g. Shopify API" />
184−
185− <CredentialTargetScopeSelector
188+ <CredentialScopeSection
186189 value={credentialTargetScope}
187190 options={credentialScopeOptions}
188191 onChange={(targetScope) => {
189192 setCredentialTargetScope(targetScope);
190193 setTokens(null);
191194 }}
192− description="Choose where new GraphQL credentials and OAuth connections are saved."
193− />
194−
195− <HttpCredentialsEditor
196− credentials={credentials}
197− onChange={setCredentials}
198− existingSecrets={secretList}
199− sourceName={identity.name}
200− targetScope={credentialTargetScope}
201− />
195+ >
196+ <HttpCredentialsEditor
197+ credentials={credentials}
198+ onChange={setCredentials}
199+ existingSecrets={secretList}
200+ sourceName={identity.name}
201+ targetScope={credentialTargetScope}
202+ />
203+ </CredentialScopeSection>
202204
203205 <section className="space-y-2.5">
204206 <div className="flex items-center justify-between gap-3">
packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx+5−1
@@ -1,3 +1,7 @@
1−export default function GraphqlSourceSummary(props: { sourceId: string }) {
1+export default function GraphqlSourceSummary(props: {
2+ sourceId: string;
3+ variant?: "badge" | "panel";
4+}) {
5+ if (props.variant === "panel") return null;
26 return <span>GraphQL · {props.sourceId}</span>;
37 }
packages/plugins/mcp/src/react/AddMcpSource.tsx+83−65
@@ -39,6 +39,7 @@ import { type HeaderState } from "@executor-js/react/plugins/secret-header-auth" · 38 unmodified lines
3939 import {
4040 displayNameFromUrl,
4141 slugifyNamespace,
42+ SourceIdentityFieldRows,
4243 SourceIdentityFields,
4344 useSourceIdentity,
4445 } from "@executor-js/react/plugins/source-identity";
@@ -50,7 +51,7 @@ import { · 5 unmodified lines
5051 type OAuthCompletionPayload,
5152 } from "@executor-js/react/plugins/oauth-sign-in";
5253 import {
53− CredentialTargetScopeSelector,
54+ CredentialScopeSection,
5455 useCredentialTargetScope,
5556 } from "@executor-js/react/plugins/credential-target-scope";
5657
@@ -294,7 +295,9 @@ export default function AddMcpSource(props: { · 237 unmodified lines
294295 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
295296 useCredentialTargetScope();
296297 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
297− const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit" });
298+ const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {
299+ mode: "promiseExit",
300+ });
298301 const secretList = useSecretPickerSecrets();
299302 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
300303 popupName: "mcp-oauth",
@@ -649,6 +652,20 @@ export default function AddMcpSource(props: { · 348 unmodified lines
649652 )}
650653 </CardStackEntryActions>
651654 </CardStackEntry>
655+ <SourceIdentityFieldRows identity={remoteIdentity} namePlaceholder="e.g. Linear" />
656+ <CardStackEntryField label="Server URL">
657+ <Input
658+ value={state.url}
659+ onChange={(e) =>
660+ dispatch({
661+ type: "set-url",
662+ url: (e.target as HTMLInputElement).value,
663+ })
664+ }
665+ placeholder="https://mcp.example.com"
666+ className="w-full font-mono text-sm"
667+ />
668+ </CardStackEntryField>
652669 </CardStackContent>
653670 </CardStack>
654671 ) : isProbing ? (
@@ -670,63 +687,47 @@ export default function AddMcpSource(props: { · 15 unmodified lines
670687 </CardStack>
671688 ) : null}
672689
673− {/* URL input */}
674− <CardStack>
675− <CardStackContent className="border-t-0">
676− <CardStackEntryField
677− label="Server URL"
678− hint={probeError ? undefined : "Supports Streamable HTTP and SSE transports."}
679− >
680− <div className="relative">
681− <Input
682− value={state.url}
683− onChange={(e) =>
684− dispatch({
685− type: "set-url",
686− url: (e.target as HTMLInputElement).value,
687− })
688− }
689− placeholder="https://mcp.example.com"
690− className="w-full pr-9 font-mono text-sm"
691− aria-invalid={probeError ? true : undefined}
692− />
693− {isProbing && (
694− <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
695− <IOSSpinner className="size-4" />
690+ {!probe && (
691+ <CardStack>
692+ <CardStackContent className="border-t-0">
693+ <CardStackEntryField label="Server URL">
694+ <div className="relative">
695+ <Input
696+ value={state.url}
697+ onChange={(e) =>
698+ dispatch({
699+ type: "set-url",
700+ url: (e.target as HTMLInputElement).value,
701+ })
702+ }
703+ placeholder="https://mcp.example.com"
704+ className="w-full pr-9 font-mono text-sm"
705+ aria-invalid={probeError ? true : undefined}
706+ />
707+ {isProbing && (
708+ <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
709+ <IOSSpinner className="size-4" />
710+ </div>
711+ )}
712+ </div>
713+ {probeError && (
714+ <div className="mt-2 space-y-2">
715+ <FieldError>{probeError}</FieldError>
716+ <Button
717+ variant="outline"
718+ size="sm"
719+ onClick={handleProbe}
720+ className="h-7 px-2 text-xs"
721+ >
722+ Try again
723+ </Button>
696724 </div>
697725 )}
698− </div>
699− {probeError && (
700− <div className="mt-2 space-y-2">
701− <FieldError>{probeError}</FieldError>
702− <Button
703− variant="outline"
704− size="sm"
705− onClick={handleProbe}
706− className="h-7 px-2 text-xs"
707− >
708− Try again
709− </Button>
710− </div>
711− )}
712− </CardStackEntryField>
713− </CardStackContent>
714− </CardStack>
715−
716− {probe && (
717− <SourceIdentityFields identity={remoteIdentity} namePlaceholder="e.g. Linear" />
726+ </CardStackEntryField>
727+ </CardStackContent>
728+ </CardStack>
718729 )}
719730
720− <CredentialTargetScopeSelector
721− value={credentialTargetScope}
722− options={credentialScopeOptions}
723− onChange={(targetScope) => {
724− setCredentialTargetScope(targetScope);
725− dispatch({ type: "oauth-reset" });
726− }}
727− description="Choose where MCP request credentials and OAuth connections are saved."
728− />
729−
730731 <HttpCredentialsEditor
731732 credentials={remoteCredentials}
732733 onChange={handleRemoteCredentialsChange}
@@ -762,18 +763,35 @@ export default function AddMcpSource(props: { · 29 unmodified lines
762763 </div>
763764
764765 {remoteAuthMode === "header" && (
765− <HeadersList
766− headers={remoteAuthHeaders}
767− onHeadersChange={setRemoteAuthHeaders}
768− existingSecrets={secretList}
769− singleHeader
770− sourceName={remoteIdentity.name}
771− targetScope={credentialTargetScope}
772− />
766+ <CredentialScopeSection
767+ value={credentialTargetScope}
768+ options={credentialScopeOptions}
769+ onChange={(targetScope) => {
770+ setCredentialTargetScope(targetScope);
771+ dispatch({ type: "oauth-reset" });
772+ }}
773+ >
774+ <HeadersList
775+ headers={remoteAuthHeaders}
776+ onHeadersChange={setRemoteAuthHeaders}
777+ existingSecrets={secretList}
778+ singleHeader
779+ sourceName={remoteIdentity.name}
780+ targetScope={credentialTargetScope}
781+ />
782+ </CredentialScopeSection>
773783 )}
774784
775785 {remoteAuthMode === "oauth2" && (
776− <>
786+ <CredentialScopeSection
787+ value={credentialTargetScope}
788+ options={credentialScopeOptions}
789+ onChange={(targetScope) => {
790+ setCredentialTargetScope(targetScope);
791+ dispatch({ type: "oauth-reset" });
792+ }}
793+ description="Choose who can use the OAuth connection."
794+ >
777795 {!tokens && state.step === "probed" && (
778796 <div className="flex flex-col gap-2">
779797 <Button onClick={handleOAuth} variant="outline">
@@ -825,7 +843,7 @@ export default function AddMcpSource(props: { · 45 unmodified lines
825843 </span>
826844 </div>
827845 )}
828− </>
846+ </CredentialScopeSection>
829847 )}
830848 </section>
831849 )}
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+280−382
@@ -29,7 +29,7 @@ import { · 28 unmodified lines
2929 type OAuthCompletionPayload,
3030 } from "@executor-js/react/plugins/oauth-sign-in";
3131 import {
32− CredentialTargetScopeSelector,
32+ CredentialScopeSection,
3333 useCredentialTargetScope,
3434 } from "@executor-js/react/plugins/credential-target-scope";
3535 import {
@@ -39,7 +39,7 @@ import { · 3 unmodified lines
3939 } from "@executor-js/react/plugins/secret-header-auth";
4040 import {
4141 slugifyNamespace,
42− SourceIdentityFields,
42+ SourceIdentityFieldRows,
4343 useSourceIdentity,
4444 } from "@executor-js/react/plugins/source-identity";
4545 import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
@@ -50,6 +50,7 @@ import { · 4 unmodified lines
5050 CollapsibleContent,
5151 CollapsibleTrigger,
5252 } from "@executor-js/react/components/collapsible";
53+import { FreeformCombobox } from "@executor-js/react/components/combobox";
5354 import {
5455 CardStack,
5556 CardStackContent,
@@ -63,12 +64,10 @@ import { FieldLabel } from "@executor-js/react/components/field"; · 7 unmodified lines
6364 import { FloatActions } from "@executor-js/react/components/float-actions";
6465 import { Input } from "@executor-js/react/components/input";
6566 import { Label } from "@executor-js/react/components/label";
66−import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
6767 import { Textarea } from "@executor-js/react/components/textarea";
6868 import { Checkbox } from "@executor-js/react/components/checkbox";
6969 import { SourceFavicon } from "@executor-js/react/components/source-favicon";
7070 import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
71−import { Skeleton } from "@executor-js/react/components/skeleton";
7271 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
7372 import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
7473 import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
@@ -97,14 +96,6 @@ const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: strin · 22 unmodified lines
9796 onSome: ({ message }) => message,
9897 });
9998
100−const substituteUrlVariables = (url: string, values: Record<string, string>): string => {
101− let out = url;
102− for (const [name, value] of Object.entries(values)) {
103− out = out.replaceAll(`{${name}}`, value);
104− }
105− return out;
106−};
107−
10899 export const openApiOAuthConnectionId = (
109100 namespaceSlug: string,
110101 flow: OAuth2Preset["flow"],
@@ -169,10 +160,16 @@ const parseStrategy = (value: string): StrategySelection => { · 58 unmodified lines
169160 if (value === "none") return { kind: "none" };
170161 if (value === "custom") return { kind: "custom" };
171162 if (value.startsWith("header:")) {
172− return { kind: "header", presetIndex: Number(value.slice("header:".length)) };
163+ return {
164+ kind: "header",
165+ presetIndex: Number(value.slice("header:".length)),
166+ };
173167 }
174168 if (value.startsWith("oauth2:")) {
175− return { kind: "oauth2", presetIndex: Number(value.slice("oauth2:".length)) };
169+ return {
170+ kind: "oauth2",
171+ presetIndex: Number(value.slice("oauth2:".length)),
172+ };
176173 }
177174 return { kind: "none" };
178175 };
@@ -220,11 +217,7 @@ export default function AddOpenApiSource(props: { · 41 unmodified lines
220217
221218 // After analysis
222219 const [preview, setPreview] = useState<SpecPreview | null>(null);
223− // -1 means the user is entering a fully custom base URL (no server selected).
224− const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
225− const [customBaseUrl, setCustomBaseUrl] = useState("");
226− // Variable selections for the currently selected server, keyed by variable name.
227− const [variableSelections, setVariableSelections] = useState<Record<string, string>>({});
220+ const [baseUrl, setBaseUrl] = useState("");
228221 const identity = useSourceIdentity({
229222 fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",
230223 fallbackNamespace: props.initialNamespace,
@@ -260,9 +253,13 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines
260253 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
261254 useCredentialTargetScope();
262255 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
263− const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit" });
256+ const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), {
257+ mode: "promiseExit",
258+ });
264259 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
265− const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });
260+ const doSetBinding = useAtomSet(setOpenApiSourceBinding, {
261+ mode: "promiseExit",
262+ });
266263 const secretList = useSecretPickerSecrets();
267264 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
268265 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -289,32 +286,37 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines
289286
290287 // ---- Derived state ----
291288
292− const servers: readonly ServerInfo[] = preview?.servers ?? [];
293− const selectedServer: ServerInfo | null =
294− selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null;
295−
296− const serverVariables: Record<string, ServerVariable> = selectedServer
297− ? Option.getOrElse(selectedServer.variables, () => ({}) as Record<string, ServerVariable>)
298− : {};
299− const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables);
300−
301− const resolvedBaseUrl =
302− selectedServer !== null
303− ? substituteUrlVariables(selectedServer.url, variableSelections)
304− : customBaseUrl.trim();
305−
306− // Helper used by analyze + server selection: build a default selection map
307− // from a server's variable defaults.
308− const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => {
289+ const expandServerOptions = (server: ServerInfo) => {
309290 const vars: Record<string, ServerVariable> = Option.getOrElse(
310291 server.variables,
311292 () => ({}) as Record<string, ServerVariable>,
312293 );
313− const out: Record<string, string> = {};
314− for (const [name, v] of Object.entries(vars)) out[name] = v.default;
315− return out;
294+ const variableEntries = Object.entries(vars);
295+ const expanded = variableEntries.reduce(
296+ (urls, [name, variable]) => {
297+ const enumValues: readonly string[] = Option.getOrElse(
298+ variable.enum,
299+ () => [] as readonly string[],
300+ );
301+ const values = enumValues.length > 0 ? enumValues : [variable.default];
302+ return urls.flatMap((url) => values.map((value) => url.replaceAll(`{${name}}`, value)));
303+ },
304+ [server.url],
305+ );
306+
307+ return expanded.map((value) => ({
308+ value,
309+ label: value,
310+ }));
316311 };
317312
313+ const servers: readonly ServerInfo[] = preview?.servers ?? [];
314+ const baseUrlOptions = Array.from(
315+ new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),
316+ );
317+
318+ const resolvedBaseUrl = baseUrl.trim();
319+
318320 const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {};
319321 const headerBindings: Array<{ slot: string; secretId: string }> = [];
320322 for (const ch of customHeaders) {
@@ -404,20 +406,16 @@ export default function AddOpenApiSource(props: { · 83 unmodified lines
404406 setPreview(result);
405407
406408 const firstServer = result.servers[0];
407− if (firstServer) {
408− setSelectedServerIndex(0);
409− setVariableSelections(defaultSelectionsFor(firstServer));
410− setCustomBaseUrl("");
411− } else {
412− setSelectedServerIndex(-1);
413− setVariableSelections({});
414− setCustomBaseUrl("");
415− }
409+ setBaseUrl(firstServer ? (expandServerOptions(firstServer)[0]?.value ?? "") : "");
416410
417411 const firstPreset = result.headerPresets[0];
418412 if (firstPreset) {
419413 setStrategy({ kind: "header", presetIndex: 0 });
420414 setCustomHeaders(entriesFromSpecPreset(firstPreset));
415+ } else if (result.oauth2Presets[0]) {
416+ setStrategy({ kind: "oauth2", presetIndex: 0 });
417+ setCustomHeaders([]);
418+ setOauth2SelectedScopes(new Set(Object.keys(result.oauth2Presets[0].scopes)));
421419 } else {
422420 // No header presets — default to "custom" so the headers editor is
423421 // visible immediately. Specs with no `security` block (e.g. Microsoft
@@ -634,7 +632,9 @@ export default function AddOpenApiSource(props: { · 210 unmodified lines
634632 baseUrl: resolvedBaseUrl || undefined,
635633 ...(hasHeaders ? { headers: configuredHeaders } : {}),
636634 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0
637− ? { queryParams: serializeHttpCredentials(runtimeCredentials).queryParams }
635+ ? {
636+ queryParams: serializeHttpCredentials(runtimeCredentials).queryParams,
637+ }
638638 : {}),
639639 ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}),
640640 },
@@ -753,70 +753,64 @@ export default function AddOpenApiSource(props: { · 112 unmodified lines
753753 </p>
754754 </div>
755755
756− {/* ── Spec input ── */}
757− <CardStack>
758− <CardStackContent className="border-t-0">
759− <CardStackEntryField
760− label="OpenAPI Spec"
761− hint={!preview ? "Paste a URL or raw JSON/YAML content." : undefined}
756+ {!preview && (
757+ <>
758+ {/* ── Spec input ── */}
759+ <CardStack>
760+ <CardStackContent className="border-t-0">
761+ <CardStackEntryField
762+ label="OpenAPI Spec"
763+ hint="Paste a URL or raw JSON/YAML content."
764+ >
765+ <div className="relative">
766+ <Textarea
767+ value={specUrl}
768+ onChange={(e) => {
769+ setSpecUrl((e.target as HTMLTextAreaElement).value);
770+ }}
771+ placeholder="https://api.example.com/openapi.json"
772+ rows={3}
773+ maxRows={10}
774+ className="font-mono text-sm"
775+ />
776+ {analyzing && (
777+ <div className="pointer-events-none absolute right-2 top-2">
778+ <IOSSpinner className="size-4" />
779+ </div>
780+ )}
781+ </div>
782+ </CardStackEntryField>
783+ </CardStackContent>
784+ </CardStack>
785+
786+ <Collapsible
787+ open={specFetchCredentialsOpen}
788+ onOpenChange={setSpecFetchCredentialsOpen}
789+ className="space-y-3"
762790 >
763− <div className="relative">
764− <Textarea
765− value={specUrl}
766− onChange={(e) => {
767− setSpecUrl((e.target as HTMLTextAreaElement).value);
768− if (preview) {
769− setPreview(null);
770− setSelectedServerIndex(-1);
771− setCustomBaseUrl("");
772− setVariableSelections({});
773− setCustomHeaders([]);
774− setStrategy({ kind: "none" });
775− setOauth2AuthState(null);
776− setOauth2Error(null);
777− }
791+ <CollapsibleTrigger asChild>
792+ <Button variant="outline" size="sm" className="self-start">
793+ {specFetchCredentialsOpen ? "Hide spec credentials" : "Add spec credentials"}
794+ </Button>
795+ </CollapsibleTrigger>
796+ <CollapsibleContent>
797+ <HttpCredentialsEditor
798+ credentials={specFetchCredentials}
799+ onChange={setSpecFetchCredentials}
800+ existingSecrets={secretList}
801+ sourceName={identity.name}
802+ targetScope={credentialTargetScope}
803+ labels={{
804+ headers: "Spec fetch headers",
805+ queryParams: "Spec fetch query parameters",
778806 }}
779− placeholder="https://api.example.com/openapi.json"
780− rows={3}
781− maxRows={10}
782− className="font-mono text-sm"
783807 />
784− {analyzing && (
785− <div className="pointer-events-none absolute right-2 top-2">
786− <IOSSpinner className="size-4" />
787− </div>
788− )}
789− </div>
790− </CardStackEntryField>
791− </CardStackContent>
792− </CardStack>
793−
794− <Collapsible
795− open={specFetchCredentialsOpen}
796− onOpenChange={setSpecFetchCredentialsOpen}
797− className="space-y-3"
798− >
799− <CollapsibleTrigger asChild>
800− <Button variant="outline" size="sm" className="self-start">
801− {specFetchCredentialsOpen ? "Hide spec credentials" : "Add spec credentials"}
802− </Button>
803− </CollapsibleTrigger>
804− <CollapsibleContent>
805− <HttpCredentialsEditor
806− credentials={specFetchCredentials}
807− onChange={setSpecFetchCredentials}
808− existingSecrets={secretList}
809− sourceName={identity.name}
810− targetScope={credentialTargetScope}
811− labels={{
812− headers: "Spec fetch headers",
813− queryParams: "Spec fetch query parameters",
814− }}
815− />
816− </CollapsibleContent>
817− </Collapsible>
818−
819− {/* ── Title card (shown below spec input after analysis) ── */}
808+ </CollapsibleContent>
809+ </Collapsible>
810+ </>
811+ )}
812+
813+ {/* ── Source information card (shown after analysis) ── */}
820814 {preview ? (
821815 <CardStack>
822816 <CardStackContent className="border-t-0">
@@ -836,18 +830,42 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines
836830 </CardStackEntryDescription>
837831 </CardStackEntryContent>
838832 </CardStackEntry>
839− </CardStackContent>
840− </CardStack>
841− ) : analyzing ? (
842− <CardStack>
843− <CardStackContent className="border-t-0">
844− <CardStackEntry>
845− <Skeleton className="size-4 shrink-0 rounded" />
846− <CardStackEntryContent>
847− <Skeleton className="h-4 w-40" />
848− <Skeleton className="mt-1 h-3 w-56" />
849− </CardStackEntryContent>
850− </CardStackEntry>
833+ <SourceIdentityFieldRows identity={identity} />
834+ <div className="grid grid-cols-1 md:grid-cols-2">
835+ <CardStackEntryField label="Base URL">
836+ <FreeformCombobox
837+ value={resolvedBaseUrl}
838+ onValueChange={setBaseUrl}
839+ options={baseUrlOptions}
840+ placeholder="https://api.example.com"
841+ emptyLabel="Use the custom URL you typed."
842+ className="w-full"
843+ inputClassName="font-mono text-sm"
844+ />
845+
846+ {!resolvedBaseUrl && (
847+ <p className="text-[11px] text-amber-600 dark:text-amber-400">
848+ A base URL is required to make requests.
849+ </p>
850+ )}
851+ </CardStackEntryField>
852+ <CardStackEntryField label="Spec URL">
853+ <Input
854+ value={specUrl}
855+ onChange={(e) => {
856+ setSpecUrl((e.target as HTMLInputElement).value);
857+ setPreview(null);
858+ setBaseUrl("");
859+ setCustomHeaders([]);
860+ setStrategy({ kind: "none" });
861+ setOauth2AuthState(null);
862+ setOauth2Error(null);
863+ }}
864+ placeholder="https://api.example.com/openapi.json"
865+ className="font-mono text-sm"
866+ />
867+ </CardStackEntryField>
868+ </div>
851869 </CardStackContent>
852870 </CardStack>
853871 ) : null}
@@ -861,147 +879,6 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
861879 {/* ── Everything below appears after analysis ── */}
862880 {preview && (
863881 <>
864− <SourceIdentityFields identity={identity} />
865−
866− <CredentialTargetScopeSelector
867− value={credentialTargetScope}
868− options={credentialScopeOptions}
869− onChange={(targetScope) => {
870− setCredentialTargetScope(targetScope);
871− setOauth2AuthState(null);
872− }}
873− description="Choose where OpenAPI credentials and OAuth connections are saved."
874− />
875−
876− {/* Base URL */}
877− <CardStack>
878− <CardStackContent className="border-t-0">
879− <CardStackEntryField label="Base URL">
880− {servers.length >= 1 && (
881− <RadioGroup
882− value={String(selectedServerIndex)}
883− onValueChange={(value) => {
884− const idx = Number(value);
885− setSelectedServerIndex(idx);
886− if (idx >= 0) {
887− const s = servers[idx];
888− if (s) setVariableSelections(defaultSelectionsFor(s));
889− } else {
890− setVariableSelections({});
891− }
892− }}
893− className="gap-1.5"
894− >
895− {servers.map((s, i) => (
896− <Label
897− key={i}
898− className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
899− selectedServerIndex === i
900− ? "border-primary/50 bg-primary/[0.03]"
901− : "border-border hover:bg-accent/50"
902− }`}
903− >
904− <RadioGroupItem value={String(i)} className="mt-0.5" />
905− <div className="min-w-0 flex-1">
906− <div className="font-mono text-xs text-foreground truncate">{s.url}</div>
907− {Option.isSome(s.description) && (
908− <div className="mt-0.5 text-[10px] text-muted-foreground">
909− {s.description.value}
910− </div>
911− )}
912− </div>
913− </Label>
914− ))}
915− <Label
916− className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
917− selectedServerIndex === -1
918− ? "border-primary/50 bg-primary/[0.03]"
919− : "border-border hover:bg-accent/50"
920− }`}
921− >
922− <RadioGroupItem value="-1" />
923− <span className="text-xs font-medium text-foreground">Custom</span>
924− </Label>
925− </RadioGroup>
926− )}
927−
928− {/* Per-variable pickers for the selected server */}
929− {selectedServer && serverVariableEntries.length > 0 && (
930− <div className="mt-2 space-y-2 rounded-lg border border-border/60 bg-muted/20 p-2.5">
931− {serverVariableEntries.map(([name, variable]) => {
932− const enumValues: readonly string[] = Option.getOrElse(
933− variable.enum,
934− () => [] as readonly string[],
935− );
936− const current = variableSelections[name] ?? variable.default;
937− return (
938− <div key={name} className="space-y-1">
939− <div className="flex items-baseline justify-between gap-2">
940− <Label className="font-mono text-[11px] text-foreground">
941− {`{${name}}`}
942− </Label>
943− {Option.isSome(variable.description) && (
944− <span className="text-[10px] text-muted-foreground truncate">
945− {variable.description.value}
946− </span>
947− )}
948− </div>
949− {enumValues.length > 0 ? (
950− <NativeSelect
951− value={current}
952− onChange={(e) =>
953− setVariableSelections((prev) => ({
954− ...prev,
955− [name]: (e.target as HTMLSelectElement).value,
956− }))
957− }
958− >
959− {enumValues.map((v) => (
960− <NativeSelectOption key={v} value={v}>
961− {v}
962− </NativeSelectOption>
963− ))}
964− </NativeSelect>
965− ) : (
966− <Input
967− value={current}
968− onChange={(e) =>
969− setVariableSelections((prev) => ({
970− ...prev,
971− [name]: (e.target as HTMLInputElement).value,
972− }))
973− }
974− className="font-mono text-xs"
975− />
976− )}
977− </div>
978− );
979− })}
980− </div>
981− )}
982−
983− {selectedServerIndex === -1 ? (
984− <Input
985− value={customBaseUrl}
986− onChange={(e) => setCustomBaseUrl((e.target as HTMLInputElement).value)}
987− placeholder="https://api.example.com"
988− className="font-mono text-sm"
989− />
990− ) : (
991− <div className="rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground">
992− {resolvedBaseUrl || "\u00A0"}
993− </div>
994− )}
995−
996− {!resolvedBaseUrl && (
997− <p className="text-[11px] text-amber-600 dark:text-amber-400">
998− A base URL is required to make requests.
999− </p>
1000− )}
1001− </CardStackEntryField>
1002− </CardStackContent>
1003− </CardStack>
1004−
1005882 <section className="space-y-2.5">
1006883 <FieldLabel>Authentication</FieldLabel>
1007884 {/* RadioGroup always renders so the static Custom + None radios
@@ -1082,13 +959,22 @@ export default function AddOpenApiSource(props: { · 74 unmodified lines
1082959
1083960 {/* Header-based auth input */}
1084961 {strategy.kind !== "none" && strategy.kind !== "oauth2" && (
1085− <HeadersList
1086− headers={customHeaders}
1087− onHeadersChange={handleHeadersChange}
1088− existingSecrets={secretList}
1089− sourceName={identity.name}
1090− targetScope={credentialTargetScope}
1091− />
962+ <CredentialScopeSection
963+ value={credentialTargetScope}
964+ options={credentialScopeOptions}
965+ onChange={(targetScope) => {
966+ setCredentialTargetScope(targetScope);
967+ setOauth2AuthState(null);
968+ }}
969+ >
970+ <HeadersList
971+ headers={customHeaders}
972+ onHeadersChange={handleHeadersChange}
973+ existingSecrets={secretList}
974+ sourceName={identity.name}
975+ targetScope={credentialTargetScope}
976+ />
977+ </CredentialScopeSection>
1092978 )}
1093979
1094980 <HttpCredentialsEditor
@@ -1104,122 +990,134 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines
1104990 {/* OAuth2 configuration */}
1105991 {selectedOAuth2Preset && (
1106992 <div className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">
1107− <div className="space-y-1.5">
1108− <FieldLabel className="text-[11px]">
1109− Redirect URL{" "}
1110− <span className="text-muted-foreground">
1111− · add this to your OAuth app's allowed redirects
1112− </span>
1113− </FieldLabel>
1114− <div className="flex items-center gap-1 rounded-md border border-border bg-background/50 px-2.5 py-1.5 font-mono text-[11px]">
1115− <span className="truncate flex-1 text-foreground">{oauth2RedirectUrl}</span>
1116− <CopyButton value={oauth2RedirectUrl} />
993+ <CredentialScopeSection
994+ value={credentialTargetScope}
995+ options={credentialScopeOptions}
996+ onChange={(targetScope) => {
997+ setCredentialTargetScope(targetScope);
998+ setOauth2AuthState(null);
999+ }}
1000+ description="Choose who can use the OAuth client secrets and connection."
1001+ >
1002+ <div className="space-y-1.5">
1003+ <FieldLabel className="text-[11px]">
1004+ Redirect URL{" "}
1005+ <span className="text-muted-foreground">
1006+ · add this to your OAuth app's allowed redirects
1007+ </span>
1008+ </FieldLabel>
1009+ <div className="flex items-center gap-1 rounded-md border border-border bg-background/50 px-2.5 py-1.5 font-mono text-[11px]">
1010+ <span className="truncate flex-1 text-foreground">{oauth2RedirectUrl}</span>
1011+ <CopyButton value={oauth2RedirectUrl} />
1012+ </div>
11171013 </div>
1118− </div>
1119− <div className="space-y-1.5">
1120− <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>
1121− <CreatableSecretPicker
1122− value={oauth2ClientIdSecretId}
1123− onSelect={(id: string) => {
1124− setOauth2ClientIdSecretId(id);
1125− setOauth2AuthState(null);
1126− }}
1127− secrets={secretList}
1128− sourceName={identity.name}
1129− secretLabel="Client ID"
1130− targetScope={credentialTargetScope}
1131− />
1132− </div>
1133− <div className="space-y-1.5">
1134− <FieldLabel className="text-[11px]">
1135− Client secret{" "}
1136− <span className="text-muted-foreground">
1137− · optional for public clients with PKCE
1138− </span>
1139− </FieldLabel>
1140− <CreatableSecretPicker
1141− value={oauth2ClientSecretSecretId}
1142− onSelect={(id: string) => {
1143− setOauth2ClientSecretSecretId(id);
1144− setOauth2AuthState(null);
1145− }}
1146− secrets={secretList}
1147− sourceName={identity.name}
1148− secretLabel="Client Secret"
1149− targetScope={credentialTargetScope}
1150− />
1151− </div>
1152− <div className="space-y-1.5">
1153− <FieldLabel className="text-[11px]">Scopes</FieldLabel>
1154− <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2">
1155− {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? (
1156− <div className="text-[11px] italic text-muted-foreground">
1157− No scopes declared by the spec.
1158− </div>
1159− ) : (
1160− Object.entries(selectedOAuth2Preset.scopes).map(([scope, description]) => (
1161− <Label key={scope} className="flex items-start gap-2 cursor-pointer py-1">
1162− <Checkbox
1163− checked={oauth2SelectedScopes.has(scope)}
1164− onCheckedChange={() => toggleOAuth2Scope(scope)}
1165− />
1166− <div className="min-w-0 flex-1">
1167− <div className="font-mono text-[11px] text-foreground">{scope}</div>
1168− {description && (
1169− <div className="text-[10px] text-muted-foreground">{description}</div>
1170− )}
1171− </div>
1172− </Label>
1173− ))
1174− )}
1014+ <div className="space-y-1.5">
1015+ <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>
1016+ <CreatableSecretPicker
1017+ value={oauth2ClientIdSecretId}
1018+ onSelect={(id: string) => {
1019+ setOauth2ClientIdSecretId(id);
1020+ setOauth2AuthState(null);
1021+ }}
1022+ secrets={secretList}
1023+ sourceName={identity.name}
1024+ secretLabel="Client ID"
1025+ targetScope={credentialTargetScope}
1026+ />
11751027 </div>
1176− </div>
1177−
1178− {oauth2Auth ? (
1179− <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">
1180− <div className="text-[11px] text-green-700 dark:text-green-400">
1181− Connected · {oauth2SelectedScopes.size} scope
1182− {oauth2SelectedScopes.size === 1 ? "" : "s"} granted
1183− </div>
1184− <Button variant="ghost" size="sm" onClick={() => setOauth2AuthState(null)}>
1185− Disconnect
1186− </Button>
1028+ <div className="space-y-1.5">
1029+ <FieldLabel className="text-[11px]">
1030+ Client secret{" "}
1031+ <span className="text-muted-foreground">
1032+ · optional for public clients with PKCE
1033+ </span>
1034+ </FieldLabel>
1035+ <CreatableSecretPicker
1036+ value={oauth2ClientSecretSecretId}
1037+ onSelect={(id: string) => {
1038+ setOauth2ClientSecretSecretId(id);
1039+ setOauth2AuthState(null);
1040+ }}
1041+ secrets={secretList}
1042+ sourceName={identity.name}
1043+ secretLabel="Client Secret"
1044+ targetScope={credentialTargetScope}
1045+ />
11871046 </div>
1188− ) : oauth2Busy ? (
1189− <div className="flex items-center gap-2">
1190− <div className="flex flex-1 items-center gap-2 rounded-md border border-border/60 bg-background/50 px-3 py-2 text-[11px] text-muted-foreground">
1191− <Spinner className="size-3.5" />
1192− Waiting for OAuth… complete the flow in the popup, or cancel to retry.
1047+ <div className="space-y-1.5">
1048+ <FieldLabel className="text-[11px]">Scopes</FieldLabel>
1049+ <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2">
1050+ {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? (
1051+ <div className="text-[11px] italic text-muted-foreground">
1052+ No scopes declared by the spec.
1053+ </div>
1054+ ) : (
1055+ Object.entries(selectedOAuth2Preset.scopes).map(([scope, description]) => (
1056+ <Label key={scope} className="flex items-start gap-2 cursor-pointer py-1">
1057+ <Checkbox
1058+ checked={oauth2SelectedScopes.has(scope)}
1059+ onCheckedChange={() => toggleOAuth2Scope(scope)}
1060+ />
1061+ <div className="min-w-0 flex-1">
1062+ <div className="font-mono text-[11px] text-foreground">{scope}</div>
1063+ {description && (
1064+ <div className="text-[10px] text-muted-foreground">
1065+ {description}
1066+ </div>
1067+ )}
1068+ </div>
1069+ </Label>
1070+ ))
1071+ )}
11931072 </div>
1194− <Button variant="ghost" size="sm" onClick={handleCancelOAuth2}>
1195− Cancel
1196− </Button>
1197− <Button variant="secondary" size="sm" onClick={handleConnectOAuth2}>
1198− Retry
1199− </Button>
12001073 </div>
1201− ) : (
1202− <div className="flex flex-col gap-1.5">
1203− <Button
1204− variant="secondary"
1205− onClick={handleConnectOAuth2}
1206− disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0}
1207− className="w-full"
1208− >
1209− Connect via OAuth
1210− </Button>
1211− <p className="text-[11px] text-muted-foreground">
1212− You can add the source without this OAuth connection. Secured operations will
1213− ask for credentials on the source detail page.
1214− </p>
1215− </div>
1216− )}
12171074
1218− {oauth2Error && (
1219− <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
1220− <p className="text-[11px] text-destructive">{oauth2Error}</p>
1221− </div>
1222− )}
1075+ {oauth2Auth ? (
1076+ <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">
1077+ <div className="text-[11px] text-green-700 dark:text-green-400">
1078+ Connected · {oauth2SelectedScopes.size} scope
1079+ {oauth2SelectedScopes.size === 1 ? "" : "s"} granted
1080+ </div>
1081+ <Button variant="ghost" size="sm" onClick={() => setOauth2AuthState(null)}>
1082+ Disconnect
1083+ </Button>
1084+ </div>
1085+ ) : oauth2Busy ? (
1086+ <div className="flex items-center gap-2">
1087+ <div className="flex flex-1 items-center gap-2 rounded-md border border-border/60 bg-background/50 px-3 py-2 text-[11px] text-muted-foreground">
1088+ <Spinner className="size-3.5" />
1089+ Waiting for OAuth… complete the flow in the popup, or cancel to retry.
1090+ </div>
1091+ <Button variant="ghost" size="sm" onClick={handleCancelOAuth2}>
1092+ Cancel
1093+ </Button>
1094+ <Button variant="secondary" size="sm" onClick={handleConnectOAuth2}>
1095+ Retry
1096+ </Button>
1097+ </div>
1098+ ) : (
1099+ <div className="flex flex-col gap-1.5">
1100+ <Button
1101+ variant="secondary"
1102+ onClick={handleConnectOAuth2}
1103+ disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0}
1104+ className="w-full"
1105+ >
1106+ Connect via OAuth
1107+ </Button>
1108+ <p className="text-[11px] text-muted-foreground">
1109+ You can add the source without this OAuth connection. Secured operations
1110+ will ask for credentials on the source detail page.
1111+ </p>
1112+ </div>
1113+ )}
1114+
1115+ {oauth2Error && (
1116+ <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
1117+ <p className="text-[11px] text-destructive">{oauth2Error}</p>
1118+ </div>
1119+ )}
1120+ </CredentialScopeSection>
12231121 </div>
12241122 )}
12251123 </section>
packages/plugins/openapi/src/sdk/preview-oauth2.test.ts+9−2
@@ -63,17 +63,24 @@ describe("previewSpec OAuth2 extraction", () => { · 62 unmodified lines
6363 expect(flow.authorizationUrl).toBe("https://example.com/oauth/authorize");
6464 expect(flow.tokenUrl).toBe("https://example.com/oauth/token");
6565 expect(Option.getOrElse(flow.refreshUrl, () => "")).toBe("https://example.com/oauth/refresh");
66− expect(flow.scopes).toEqual({ read: "Read access", write: "Write access" });
66+ expect(flow.scopes).toEqual({
67+ read: "Read access",
68+ write: "Write access",
69+ });
6770
6871 // A preset should be generated for this flow.
6972 expect(preview.oauth2Presets).toHaveLength(1);
73+ expect(preview.headerPresets).toHaveLength(0);
7074 const preset = preview.oauth2Presets[0]!;
7175 expect(preset.flow).toBe("authorizationCode");
7276 expect(preset.securitySchemeName).toBe("oauth_app");
7377 expect(preset.tokenUrl).toBe("https://example.com/oauth/token");
7478 expect(preset.label).toContain("Authorization Code");
7579 expect(preset.label).toContain("oauth_app");
76− expect(preset.scopes).toEqual({ read: "Read access", write: "Write access" });
80+ expect(preset.scopes).toEqual({
81+ read: "Read access",
82+ write: "Write access",
83+ });
7784 }),
7885 );
7986
packages/plugins/openapi/src/sdk/preview.ts+16−2
@@ -274,16 +274,30 @@ const buildHeaderPresets = ( · 273 unmodified lines
274274 labelParts.push(scheme.name);
275275 } else if (scheme.type === "apiKey") {
276276 labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`);
277+ } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
278+ return [];
277279 } else {
278280 labelParts.push(scheme.name);
279281 }
280282 }
281283
282284 if (Object.keys(headers).length === 0 && resolved.length > 0) {
283− return [new HeaderPreset({ label: labelParts.join(" + "), headers: {}, secretHeaders: [] })];
285+ return [
286+ new HeaderPreset({
287+ label: labelParts.join(" + "),
288+ headers: {},
289+ secretHeaders: [],
290+ }),
291+ ];
284292 }
285293
286− return [new HeaderPreset({ label: labelParts.join(" + "), headers, secretHeaders })];
294+ return [
295+ new HeaderPreset({
296+ label: labelParts.join(" + "),
297+ headers,
298+ secretHeaders,
299+ }),
300+ ];
287301 });
288302 };
289303
packages/react/src/components/combobox.tsx+65−1
@@ -45,18 +45,23 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { · 44 unmodified lines
4545
4646 function ComboboxInput({
4747 className,
48+ inputClassName,
4849 children,
4950 disabled = false,
5051 showTrigger = true,
5152 showClear = false,
5253 ...props
5354 }: ComboboxPrimitive.Input.Props & {
55+ inputClassName?: string;
5456 showTrigger?: boolean;
5557 showClear?: boolean;
5658 }) {
5759 return (
5860 <InputGroup className={cn("w-auto", className)}>
59− <ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
61+ <ComboboxPrimitive.Input
62+ render={<InputGroupInput disabled={disabled} className={inputClassName} />}
63+ {...props}
64+ />
6065 <InputGroupAddon align="inline-end">
6166 {showTrigger && (
6267 <InputGroupButton
@@ -257,6 +262,64 @@ function useComboboxAnchor() { · 194 unmodified lines
257262 return React.useRef<HTMLDivElement | null>(null);
258263 }
259264
265+export interface FreeformComboboxOption {
266+ readonly value: string;
267+ readonly label?: React.ReactNode;
268+ readonly description?: React.ReactNode;
269+}
270+
271+function FreeformCombobox(props: {
272+ readonly value: string;
273+ readonly onValueChange: (value: string) => void;
274+ readonly options: readonly FreeformComboboxOption[];
275+ readonly placeholder?: string;
276+ readonly emptyLabel?: React.ReactNode;
277+ readonly className?: string;
278+ readonly inputClassName?: string;
279+ readonly disabled?: boolean;
280+}) {
281+ const selectedValue = props.options.some((option) => option.value === props.value)
282+ ? props.value
283+ : null;
284+
285+ return (
286+ <Combobox
287+ items={props.options.map((option) => option.value)}
288+ inputValue={props.value}
289+ value={selectedValue}
290+ onInputValueChange={props.onValueChange}
291+ onValueChange={(value) => {
292+ if (value !== null) props.onValueChange(value);
293+ }}
294+ >
295+ <ComboboxInput
296+ placeholder={props.placeholder}
297+ className={props.className}
298+ inputClassName={props.inputClassName}
299+ disabled={props.disabled}
300+ showClear={props.value.length > 0}
301+ />
302+ <ComboboxContent>
303+ <ComboboxEmpty>{props.emptyLabel ?? "No options"}</ComboboxEmpty>
304+ <ComboboxList>
305+ {props.options.map((option) => (
306+ <ComboboxItem key={option.value} value={option.value}>
307+ <div className="min-w-0 flex-1">
308+ <div className="truncate">{option.label ?? option.value}</div>
309+ {option.description && (
310+ <div className="mt-0.5 truncate text-[10px] text-muted-foreground">
311+ {option.description}
312+ </div>
313+ )}
314+ </div>
315+ </ComboboxItem>
316+ ))}
317+ </ComboboxList>
318+ </ComboboxContent>
319+ </Combobox>
320+ );
321+}
322+
260323 export {
261324 Combobox,
262325 ComboboxInput,
@@ -273,5 +336,6 @@ export { · 10 unmodified lines
273336 ComboboxChipsInput,
274337 ComboboxTrigger,
275338 ComboboxValue,
339+ FreeformCombobox,
276340 useComboboxAnchor,
277341 };
packages/react/src/plugins/credential-target-scope.tsx+23−0
@@ -1,4 +1,5 @@
11 import { useEffect, useMemo, useState } from "react";
2+import type { ReactNode } from "react";
23
34 import type { ScopeId } from "@executor-js/sdk";
45
@@ -131,3 +132,25 @@ export function CredentialTargetScopeSelector(props: { · 126 unmodified lines
131132 </CardStack>
132133 );
133134 }
135+
136+export function CredentialScopeSection(props: {
137+ readonly value: ScopeId;
138+ readonly options: readonly CredentialTargetScopeOption[];
139+ readonly onChange: (scope: ScopeId) => void;
140+ readonly children: ReactNode;
141+ readonly title?: string;
142+ readonly description?: string;
143+}) {
144+ return (
145+ <div className="space-y-3">
146+ <CredentialTargetScopeSelector
147+ value={props.value}
148+ options={props.options}
149+ onChange={props.onChange}
150+ title={props.title ?? "Save credentials to"}
151+ description={props.description ?? "Choose who can use the credentials attached below."}
152+ />
153+ {props.children}
154+ </div>
155+ );
156+}
packages/react/src/plugins/source-identity.tsx+46−22
@@ -108,33 +108,57 @@ export function SourceIdentityFields({ · 107 unmodified lines
108108 namespaceHint ??
109109 (namespaceReadOnly
110110 ? "The namespace is part of the source's identity and cannot be changed."
111− : "Prefix for the tool names. Auto-derived from the display name.");
111+ : undefined);
112112
113113 return (
114114 <CardStack>
115115 <CardStackContent className="border-t-0">
116− <CardStackEntryField label={nameLabel}>
117− <Input
118− value={identity.name}
119− onChange={(e) => identity.setName((e.target as HTMLInputElement).value)}
120− placeholder={namePlaceholder}
121− className="text-sm"
122− />
123− </CardStackEntryField>
124− <CardStackEntryField
125− label="Namespace"
126− description={namespaceReadOnly ? undefined : "(optional)"}
127− hint={effectiveNamespaceHint}
128− >
129− <Input
130− value={identity.namespace}
131− onChange={(e) => identity.setNamespace((e.target as HTMLInputElement).value)}
132− placeholder={namespacePlaceholder}
133− className="font-mono text-sm"
134− disabled={namespaceReadOnly}
135− />
136− </CardStackEntryField>
116+ <SourceIdentityFieldRows
117+ identity={identity}
118+ namePlaceholder={namePlaceholder}
119+ namespacePlaceholder={namespacePlaceholder}
120+ nameLabel={nameLabel}
121+ namespaceHint={effectiveNamespaceHint}
122+ namespaceReadOnly={namespaceReadOnly}
123+ />
137124 </CardStackContent>
138125 </CardStack>
139126 );
140127 }
128+
129+export function SourceIdentityFieldRows({
130+ identity,
131+ namePlaceholder = "e.g. Sentry API",
132+ namespacePlaceholder = "sentry_api",
133+ nameLabel = "Display Name",
134+ namespaceHint,
135+ namespaceReadOnly = false,
136+}: SourceIdentityFieldsProps) {
137+ const effectiveNamespaceHint =
138+ namespaceHint ??
139+ (namespaceReadOnly
140+ ? "The namespace is part of the source's identity and cannot be changed."
141+ : undefined);
142+
143+ return (
144+ <div className="grid grid-cols-1 md:grid-cols-2">
145+ <CardStackEntryField label={nameLabel}>
146+ <Input
147+ value={identity.name}
148+ onChange={(e) => identity.setName((e.target as HTMLInputElement).value)}
149+ placeholder={namePlaceholder}
150+ className="text-sm"
151+ />
152+ </CardStackEntryField>
153+ <CardStackEntryField label="Namespace" hint={effectiveNamespaceHint}>
154+ <Input
155+ value={identity.namespace}
156+ onChange={(e) => identity.setNamespace((e.target as HTMLInputElement).value)}
157+ placeholder={namespacePlaceholder}
158+ className="font-mono text-sm"
159+ disabled={namespaceReadOnly}
160+ />
161+ </CardStackEntryField>
162+ </div>
163+ );
164+}