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
15import {15import {
16 displayNameFromUrl,16 displayNameFromUrl,
17 slugifyNamespace,17 slugifyNamespace,
18 SourceIdentityFields,18 SourceIdentityFieldRows,
19 useSourceIdentity,19 useSourceIdentity,
20} from "@executor-js/react/plugins/source-identity";20} from "@executor-js/react/plugins/source-identity";
21import {21import {
@@ -25,7 +25,7 @@ import { · 3 unmodified lines
25 type OAuthCompletionPayload,25 type OAuthCompletionPayload,
26} from "@executor-js/react/plugins/oauth-sign-in";26} from "@executor-js/react/plugins/oauth-sign-in";
27import {27import {
28 CredentialTargetScopeSelector,28 CredentialScopeSection,
29 useCredentialTargetScope,29 useCredentialTargetScope,
30} from "@executor-js/react/plugins/credential-target-scope";30} from "@executor-js/react/plugins/credential-target-scope";
31import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";31import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
@@ -72,7 +72,9 @@ export default function AddGraphqlSource(props: { · 40 unmodified lines
72 const scopeId = useScope();72 const scopeId = useScope();
73 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =73 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
74 useCredentialTargetScope();74 useCredentialTargetScope();
75 const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit" });75 const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), {
76 mode: "promiseExit",
77 });
76 const secretList = useSecretPickerSecrets();78 const secretList = useSecretPickerSecrets();
77 const oauth = useOAuthPopupFlow({79 const oauth = useOAuthPopupFlow({
78 popupName: "graphql-oauth",80 popupName: "graphql-oauth",
@@ -138,7 +140,9 @@ export default function AddGraphqlSource(props: { · 59 unmodified lines
138 namespace,140 namespace,
139 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),141 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),
140 ...(Object.keys(queryParams).length > 0142 ...(Object.keys(queryParams).length > 0
141 ? { queryParams: queryParams as Record<string, GraphqlCredentialInput> }143 ? {
144 queryParams: queryParams as Record<string, GraphqlCredentialInput>,
145 }
142 : {}),146 : {}),
143 credentialTargetScope,147 credentialTargetScope,
144 ...(authMode === "oauth2" && tokens148 ...(authMode === "oauth2" && tokens
@@ -177,28 +181,26 @@ export default function AddGraphqlSource(props: { · 32 unmodified lines
177 className="font-mono text-sm"181 className="font-mono text-sm"
178 />182 />
179 </CardStackEntryField>183 </CardStackEntryField>
184 <SourceIdentityFieldRows identity={identity} namePlaceholder="e.g. Shopify API" />
180 </CardStackContent>185 </CardStackContent>
181 </CardStack>186 </CardStack>
182 187
183 <SourceIdentityFields identity={identity} namePlaceholder="e.g. Shopify API" />188 <CredentialScopeSection
184
185 <CredentialTargetScopeSelector
186 value={credentialTargetScope}189 value={credentialTargetScope}
187 options={credentialScopeOptions}190 options={credentialScopeOptions}
188 onChange={(targetScope) => {191 onChange={(targetScope) => {
189 setCredentialTargetScope(targetScope);192 setCredentialTargetScope(targetScope);
190 setTokens(null);193 setTokens(null);
191 }}194 }}
192 description="Choose where new GraphQL credentials and OAuth connections are saved."195 >
193 />196 <HttpCredentialsEditor
194 197 credentials={credentials}
195 <HttpCredentialsEditor198 onChange={setCredentials}
196 credentials={credentials}199 existingSecrets={secretList}
197 onChange={setCredentials}200 sourceName={identity.name}
198 existingSecrets={secretList}201 targetScope={credentialTargetScope}
199 sourceName={identity.name}202 />
200 targetScope={credentialTargetScope}203 </CredentialScopeSection>
201 />
202 204
203 <section className="space-y-2.5">205 <section className="space-y-2.5">
204 <div className="flex items-center justify-between gap-3">206 <div className="flex items-center justify-between gap-3">
packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx+5−1
@@ -1,3 +1,7 @@
1export default function GraphqlSourceSummary(props: { sourceId: string }) {1export default function GraphqlSourceSummary(props: {
2 sourceId: string;
3 variant?: "badge" | "panel";
4}) {
5 if (props.variant === "panel") return null;
2 return <span>GraphQL · {props.sourceId}</span>;6 return <span>GraphQL · {props.sourceId}</span>;
3}7}
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
39import {39import {
40 displayNameFromUrl,40 displayNameFromUrl,
41 slugifyNamespace,41 slugifyNamespace,
42 SourceIdentityFieldRows,
42 SourceIdentityFields,43 SourceIdentityFields,
43 useSourceIdentity,44 useSourceIdentity,
44} from "@executor-js/react/plugins/source-identity";45} from "@executor-js/react/plugins/source-identity";
@@ -50,7 +51,7 @@ import { · 5 unmodified lines
50 type OAuthCompletionPayload,51 type OAuthCompletionPayload,
51} from "@executor-js/react/plugins/oauth-sign-in";52} from "@executor-js/react/plugins/oauth-sign-in";
52import {53import {
53 CredentialTargetScopeSelector,54 CredentialScopeSection,
54 useCredentialTargetScope,55 useCredentialTargetScope,
55} from "@executor-js/react/plugins/credential-target-scope";56} from "@executor-js/react/plugins/credential-target-scope";
56 57
@@ -294,7 +295,9 @@ export default function AddMcpSource(props: { · 237 unmodified lines
294 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =295 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
295 useCredentialTargetScope();296 useCredentialTargetScope();
296 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });297 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
297 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit" });298 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {
299 mode: "promiseExit",
300 });
298 const secretList = useSecretPickerSecrets();301 const secretList = useSecretPickerSecrets();
299 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({302 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
300 popupName: "mcp-oauth",303 popupName: "mcp-oauth",
@@ -649,6 +652,20 @@ export default function AddMcpSource(props: { · 348 unmodified lines
649 )}652 )}
650 </CardStackEntryActions>653 </CardStackEntryActions>
651 </CardStackEntry>654 </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>
652 </CardStackContent>669 </CardStackContent>
653 </CardStack>670 </CardStack>
654 ) : isProbing ? (671 ) : isProbing ? (
@@ -670,63 +687,47 @@ export default function AddMcpSource(props: { · 15 unmodified lines
670 </CardStack>687 </CardStack>
671 ) : null}688 ) : null}
672 689
673 {/* URL input */}690 {!probe && (
674 <CardStack>691 <CardStack>
675 <CardStackContent className="border-t-0">692 <CardStackContent className="border-t-0">
676 <CardStackEntryField693 <CardStackEntryField label="Server URL">
677 label="Server URL"694 <div className="relative">
678 hint={probeError ? undefined : "Supports Streamable HTTP and SSE transports."}695 <Input
679 >696 value={state.url}
680 <div className="relative">697 onChange={(e) =>
681 <Input698 dispatch({
682 value={state.url}699 type: "set-url",
683 onChange={(e) =>700 url: (e.target as HTMLInputElement).value,
684 dispatch({701 })
685 type: "set-url",702 }
686 url: (e.target as HTMLInputElement).value,703 placeholder="https://mcp.example.com"
687 })704 className="w-full pr-9 font-mono text-sm"
688 }705 aria-invalid={probeError ? true : undefined}
689 placeholder="https://mcp.example.com"706 />
690 className="w-full pr-9 font-mono text-sm"707 {isProbing && (
691 aria-invalid={probeError ? true : undefined}708 <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
692 />709 <IOSSpinner className="size-4" />
693 {isProbing && (710 </div>
694 <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">711 )}
695 <IOSSpinner className="size-4" />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>
696 </div>724 </div>
697 )}725 )}
698 </div>726 </CardStackEntryField>
699 {probeError && (727 </CardStackContent>
700 <div className="mt-2 space-y-2">728 </CardStack>
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" />
718 )}729 )}
719 730
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
730 <HttpCredentialsEditor731 <HttpCredentialsEditor
731 credentials={remoteCredentials}732 credentials={remoteCredentials}
732 onChange={handleRemoteCredentialsChange}733 onChange={handleRemoteCredentialsChange}
@@ -762,18 +763,35 @@ export default function AddMcpSource(props: { · 29 unmodified lines
762 </div>763 </div>
763 764
764 {remoteAuthMode === "header" && (765 {remoteAuthMode === "header" && (
765 <HeadersList766 <CredentialScopeSection
766 headers={remoteAuthHeaders}767 value={credentialTargetScope}
767 onHeadersChange={setRemoteAuthHeaders}768 options={credentialScopeOptions}
768 existingSecrets={secretList}769 onChange={(targetScope) => {
769 singleHeader770 setCredentialTargetScope(targetScope);
770 sourceName={remoteIdentity.name}771 dispatch({ type: "oauth-reset" });
771 targetScope={credentialTargetScope}772 }}
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>
773 )}783 )}
774 784
775 {remoteAuthMode === "oauth2" && (785 {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 >
777 {!tokens && state.step === "probed" && (795 {!tokens && state.step === "probed" && (
778 <div className="flex flex-col gap-2">796 <div className="flex flex-col gap-2">
779 <Button onClick={handleOAuth} variant="outline">797 <Button onClick={handleOAuth} variant="outline">
@@ -825,7 +843,7 @@ export default function AddMcpSource(props: { · 45 unmodified lines
825 </span>843 </span>
826 </div>844 </div>
827 )}845 )}
828 </>846 </CredentialScopeSection>
829 )}847 )}
830 </section>848 </section>
831 )}849 )}
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+280−382
@@ -29,7 +29,7 @@ import { · 28 unmodified lines
29 type OAuthCompletionPayload,29 type OAuthCompletionPayload,
30} from "@executor-js/react/plugins/oauth-sign-in";30} from "@executor-js/react/plugins/oauth-sign-in";
31import {31import {
32 CredentialTargetScopeSelector,32 CredentialScopeSection,
33 useCredentialTargetScope,33 useCredentialTargetScope,
34} from "@executor-js/react/plugins/credential-target-scope";34} from "@executor-js/react/plugins/credential-target-scope";
35import {35import {
@@ -39,7 +39,7 @@ import { · 3 unmodified lines
39} from "@executor-js/react/plugins/secret-header-auth";39} from "@executor-js/react/plugins/secret-header-auth";
40import {40import {
41 slugifyNamespace,41 slugifyNamespace,
42 SourceIdentityFields,42 SourceIdentityFieldRows,
43 useSourceIdentity,43 useSourceIdentity,
44} from "@executor-js/react/plugins/source-identity";44} from "@executor-js/react/plugins/source-identity";
45import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";45import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
@@ -50,6 +50,7 @@ import { · 4 unmodified lines
50 CollapsibleContent,50 CollapsibleContent,
51 CollapsibleTrigger,51 CollapsibleTrigger,
52} from "@executor-js/react/components/collapsible";52} from "@executor-js/react/components/collapsible";
53import { FreeformCombobox } from "@executor-js/react/components/combobox";
53import {54import {
54 CardStack,55 CardStack,
55 CardStackContent,56 CardStackContent,
@@ -63,12 +64,10 @@ import { FieldLabel } from "@executor-js/react/components/field"; · 7 unmodified lines
63import { FloatActions } from "@executor-js/react/components/float-actions";64import { FloatActions } from "@executor-js/react/components/float-actions";
64import { Input } from "@executor-js/react/components/input";65import { Input } from "@executor-js/react/components/input";
65import { Label } from "@executor-js/react/components/label";66import { Label } from "@executor-js/react/components/label";
66import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
67import { Textarea } from "@executor-js/react/components/textarea";67import { Textarea } from "@executor-js/react/components/textarea";
68import { Checkbox } from "@executor-js/react/components/checkbox";68import { Checkbox } from "@executor-js/react/components/checkbox";
69import { SourceFavicon } from "@executor-js/react/components/source-favicon";69import { SourceFavicon } from "@executor-js/react/components/source-favicon";
70import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";70import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
71import { Skeleton } from "@executor-js/react/components/skeleton";
72import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";71import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
73import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";72import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
74import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";73import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
@@ -97,14 +96,6 @@ const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: strin · 22 unmodified lines
97 onSome: ({ message }) => message,96 onSome: ({ message }) => message,
98 });97 });
99 98
100const 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
108export const openApiOAuthConnectionId = (99export const openApiOAuthConnectionId = (
109 namespaceSlug: string,100 namespaceSlug: string,
110 flow: OAuth2Preset["flow"],101 flow: OAuth2Preset["flow"],
@@ -169,10 +160,16 @@ const parseStrategy = (value: string): StrategySelection => { · 58 unmodified lines
169 if (value === "none") return { kind: "none" };160 if (value === "none") return { kind: "none" };
170 if (value === "custom") return { kind: "custom" };161 if (value === "custom") return { kind: "custom" };
171 if (value.startsWith("header:")) {162 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 };
173 }167 }
174 if (value.startsWith("oauth2:")) {168 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 };
176 }173 }
177 return { kind: "none" };174 return { kind: "none" };
178};175};
@@ -220,11 +217,7 @@ export default function AddOpenApiSource(props: { · 41 unmodified lines
220 217
221 // After analysis218 // After analysis
222 const [preview, setPreview] = useState<SpecPreview | null>(null);219 const [preview, setPreview] = useState<SpecPreview | null>(null);
223 // -1 means the user is entering a fully custom base URL (no server selected).220 const [baseUrl, setBaseUrl] = useState("");
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>>({});
228 const identity = useSourceIdentity({221 const identity = useSourceIdentity({
229 fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",222 fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",
230 fallbackNamespace: props.initialNamespace,223 fallbackNamespace: props.initialNamespace,
@@ -260,9 +253,13 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines
260 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =253 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
261 useCredentialTargetScope();254 useCredentialTargetScope();
262 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });255 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
263 const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit" });256 const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), {
257 mode: "promiseExit",
258 });
264 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });259 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
265 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });260 const doSetBinding = useAtomSet(setOpenApiSourceBinding, {
261 mode: "promiseExit",
262 });
266 const secretList = useSecretPickerSecrets();263 const secretList = useSecretPickerSecrets();
267 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({264 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
268 popupName: OPENAPI_OAUTH_POPUP_NAME,265 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -289,32 +286,37 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines
289 286
290 // ---- Derived state ----287 // ---- Derived state ----
291 288
292 const servers: readonly ServerInfo[] = preview?.servers ?? [];289 const expandServerOptions = (server: ServerInfo) => {
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> => {
309 const vars: Record<string, ServerVariable> = Option.getOrElse(290 const vars: Record<string, ServerVariable> = Option.getOrElse(
310 server.variables,291 server.variables,
311 () => ({}) as Record<string, ServerVariable>,292 () => ({}) as Record<string, ServerVariable>,
312 );293 );
313 const out: Record<string, string> = {};294 const variableEntries = Object.entries(vars);
314 for (const [name, v] of Object.entries(vars)) out[name] = v.default;295 const expanded = variableEntries.reduce(
315 return out;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 }));
316 };311 };
317 312
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
318 const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {};320 const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {};
319 const headerBindings: Array<{ slot: string; secretId: string }> = [];321 const headerBindings: Array<{ slot: string; secretId: string }> = [];
320 for (const ch of customHeaders) {322 for (const ch of customHeaders) {
@@ -404,20 +406,16 @@ export default function AddOpenApiSource(props: { · 83 unmodified lines
404 setPreview(result);406 setPreview(result);
405 407
406 const firstServer = result.servers[0];408 const firstServer = result.servers[0];
407 if (firstServer) {409 setBaseUrl(firstServer ? (expandServerOptions(firstServer)[0]?.value ?? "") : "");
408 setSelectedServerIndex(0);
409 setVariableSelections(defaultSelectionsFor(firstServer));
410 setCustomBaseUrl("");
411 } else {
412 setSelectedServerIndex(-1);
413 setVariableSelections({});
414 setCustomBaseUrl("");
415 }
416 410
417 const firstPreset = result.headerPresets[0];411 const firstPreset = result.headerPresets[0];
418 if (firstPreset) {412 if (firstPreset) {
419 setStrategy({ kind: "header", presetIndex: 0 });413 setStrategy({ kind: "header", presetIndex: 0 });
420 setCustomHeaders(entriesFromSpecPreset(firstPreset));414 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)));
421 } else {419 } else {
422 // No header presets — default to "custom" so the headers editor is420 // No header presets — default to "custom" so the headers editor is
423 // visible immediately. Specs with no `security` block (e.g. Microsoft421 // visible immediately. Specs with no `security` block (e.g. Microsoft
@@ -634,7 +632,9 @@ export default function AddOpenApiSource(props: { · 210 unmodified lines
634 baseUrl: resolvedBaseUrl || undefined,632 baseUrl: resolvedBaseUrl || undefined,
635 ...(hasHeaders ? { headers: configuredHeaders } : {}),633 ...(hasHeaders ? { headers: configuredHeaders } : {}),
636 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0634 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0
637 ? { queryParams: serializeHttpCredentials(runtimeCredentials).queryParams }635 ? {
636 queryParams: serializeHttpCredentials(runtimeCredentials).queryParams,
637 }
638 : {}),638 : {}),
639 ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}),639 ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}),
640 },640 },
@@ -753,70 +753,64 @@ export default function AddOpenApiSource(props: { · 112 unmodified lines
753 </p>753 </p>
754 </div>754 </div>
755 755
756 {/* ── Spec input ── */}756 {!preview && (
757 <CardStack>757 <>
758 <CardStackContent className="border-t-0">758 {/* ── Spec input ── */}
759 <CardStackEntryField759 <CardStack>
760 label="OpenAPI Spec"760 <CardStackContent className="border-t-0">
761 hint={!preview ? "Paste a URL or raw JSON/YAML content." : undefined}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"
762 >790 >
763 <div className="relative">791 <CollapsibleTrigger asChild>
764 <Textarea792 <Button variant="outline" size="sm" className="self-start">
765 value={specUrl}793 {specFetchCredentialsOpen ? "Hide spec credentials" : "Add spec credentials"}
766 onChange={(e) => {794 </Button>
767 setSpecUrl((e.target as HTMLTextAreaElement).value);795 </CollapsibleTrigger>
768 if (preview) {796 <CollapsibleContent>
769 setPreview(null);797 <HttpCredentialsEditor
770 setSelectedServerIndex(-1);798 credentials={specFetchCredentials}
771 setCustomBaseUrl("");799 onChange={setSpecFetchCredentials}
772 setVariableSelections({});800 existingSecrets={secretList}
773 setCustomHeaders([]);801 sourceName={identity.name}
774 setStrategy({ kind: "none" });802 targetScope={credentialTargetScope}
775 setOauth2AuthState(null);803 labels={{
776 setOauth2Error(null);804 headers: "Spec fetch headers",
777 }805 queryParams: "Spec fetch query parameters",
778 }}806 }}
779 placeholder="https://api.example.com/openapi.json"
780 rows={3}
781 maxRows={10}
782 className="font-mono text-sm"
783 />807 />
784 {analyzing && (808 </CollapsibleContent>
785 <div className="pointer-events-none absolute right-2 top-2">809 </Collapsible>
786 <IOSSpinner className="size-4" />810 </>
787 </div>811 )}
788 )}812
789 </div>813 {/* ── Source information card (shown after analysis) ── */}
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) ── */}
820 {preview ? (814 {preview ? (
821 <CardStack>815 <CardStack>
822 <CardStackContent className="border-t-0">816 <CardStackContent className="border-t-0">
@@ -836,18 +830,42 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines
836 </CardStackEntryDescription>830 </CardStackEntryDescription>
837 </CardStackEntryContent>831 </CardStackEntryContent>
838 </CardStackEntry>832 </CardStackEntry>
839 </CardStackContent>833 <SourceIdentityFieldRows identity={identity} />
840 </CardStack>834 <div className="grid grid-cols-1 md:grid-cols-2">
841 ) : analyzing ? (835 <CardStackEntryField label="Base URL">
842 <CardStack>836 <FreeformCombobox
843 <CardStackContent className="border-t-0">837 value={resolvedBaseUrl}
844 <CardStackEntry>838 onValueChange={setBaseUrl}
845 <Skeleton className="size-4 shrink-0 rounded" />839 options={baseUrlOptions}
846 <CardStackEntryContent>840 placeholder="https://api.example.com"
847 <Skeleton className="h-4 w-40" />841 emptyLabel="Use the custom URL you typed."
848 <Skeleton className="mt-1 h-3 w-56" />842 className="w-full"
849 </CardStackEntryContent>843 inputClassName="font-mono text-sm"
850 </CardStackEntry>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>
851 </CardStackContent>869 </CardStackContent>
852 </CardStack>870 </CardStack>
853 ) : null}871 ) : null}
@@ -861,147 +879,6 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
861 {/* ── Everything below appears after analysis ── */}879 {/* ── Everything below appears after analysis ── */}
862 {preview && (880 {preview && (
863 <>881 <>
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
1005 <section className="space-y-2.5">882 <section className="space-y-2.5">
1006 <FieldLabel>Authentication</FieldLabel>883 <FieldLabel>Authentication</FieldLabel>
1007 {/* RadioGroup always renders so the static Custom + None radios884 {/* RadioGroup always renders so the static Custom + None radios
@@ -1082,13 +959,22 @@ export default function AddOpenApiSource(props: { · 74 unmodified lines
1082 959
1083 {/* Header-based auth input */}960 {/* Header-based auth input */}
1084 {strategy.kind !== "none" && strategy.kind !== "oauth2" && (961 {strategy.kind !== "none" && strategy.kind !== "oauth2" && (
1085 <HeadersList962 <CredentialScopeSection
1086 headers={customHeaders}963 value={credentialTargetScope}
1087 onHeadersChange={handleHeadersChange}964 options={credentialScopeOptions}
1088 existingSecrets={secretList}965 onChange={(targetScope) => {
1089 sourceName={identity.name}966 setCredentialTargetScope(targetScope);
1090 targetScope={credentialTargetScope}967 setOauth2AuthState(null);
1091 />968 }}
969 >
970 <HeadersList
971 headers={customHeaders}
972 onHeadersChange={handleHeadersChange}
973 existingSecrets={secretList}
974 sourceName={identity.name}
975 targetScope={credentialTargetScope}
976 />
977 </CredentialScopeSection>
1092 )}978 )}
1093 979
1094 <HttpCredentialsEditor980 <HttpCredentialsEditor
@@ -1104,122 +990,134 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines
1104 {/* OAuth2 configuration */}990 {/* OAuth2 configuration */}
1105 {selectedOAuth2Preset && (991 {selectedOAuth2Preset && (
1106 <div className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">992 <div className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">
1107 <div className="space-y-1.5">993 <CredentialScopeSection
1108 <FieldLabel className="text-[11px]">994 value={credentialTargetScope}
1109 Redirect URL{" "}995 options={credentialScopeOptions}
1110 <span className="text-muted-foreground">996 onChange={(targetScope) => {
1111 · add this to your OAuth app's allowed redirects997 setCredentialTargetScope(targetScope);
1112 </span>998 setOauth2AuthState(null);
1113 </FieldLabel>999 }}
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]">1000 description="Choose who can use the OAuth client secrets and connection."
1115 <span className="truncate flex-1 text-foreground">{oauth2RedirectUrl}</span>1001 >
1116 <CopyButton value={oauth2RedirectUrl} />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>
1117 </div>1013 </div>
1118 </div>1014 <div className="space-y-1.5">
1119 <div className="space-y-1.5">1015 <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>
1120 <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>1016 <CreatableSecretPicker
1121 <CreatableSecretPicker1017 value={oauth2ClientIdSecretId}
1122 value={oauth2ClientIdSecretId}1018 onSelect={(id: string) => {
1123 onSelect={(id: string) => {1019 setOauth2ClientIdSecretId(id);
1124 setOauth2ClientIdSecretId(id);1020 setOauth2AuthState(null);
1125 setOauth2AuthState(null);1021 }}
1126 }}1022 secrets={secretList}
1127 secrets={secretList}1023 sourceName={identity.name}
1128 sourceName={identity.name}1024 secretLabel="Client ID"
1129 secretLabel="Client ID"1025 targetScope={credentialTargetScope}
1130 targetScope={credentialTargetScope}1026 />
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 )}
1175 </div>1027 </div>
1176 </div>1028 <div className="space-y-1.5">
1177 1029 <FieldLabel className="text-[11px]">
1178 {oauth2Auth ? (1030 Client secret{" "}
1179 <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">1031 <span className="text-muted-foreground">
1180 <div className="text-[11px] text-green-700 dark:text-green-400">1032 · optional for public clients with PKCE
1181 Connected · {oauth2SelectedScopes.size} scope1033 </span>
1182 {oauth2SelectedScopes.size === 1 ? "" : "s"} granted1034 </FieldLabel>
1183 </div>1035 <CreatableSecretPicker
1184 <Button variant="ghost" size="sm" onClick={() => setOauth2AuthState(null)}>1036 value={oauth2ClientSecretSecretId}
1185 Disconnect1037 onSelect={(id: string) => {
1186 </Button>1038 setOauth2ClientSecretSecretId(id);
1039 setOauth2AuthState(null);
1040 }}
1041 secrets={secretList}
1042 sourceName={identity.name}
1043 secretLabel="Client Secret"
1044 targetScope={credentialTargetScope}
1045 />
1187 </div>1046 </div>
1188 ) : oauth2Busy ? (1047 <div className="space-y-1.5">
1189 <div className="flex items-center gap-2">1048 <FieldLabel className="text-[11px]">Scopes</FieldLabel>
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">1049 <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2">
1191 <Spinner className="size-3.5" />1050 {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? (
1192 Waiting for OAuth… complete the flow in the popup, or cancel to retry.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 )}
1193 </div>1072 </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>
1200 </div>1073 </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 )}
1217 1074
1218 {oauth2Error && (1075 {oauth2Auth ? (
1219 <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">1076 <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">
1220 <p className="text-[11px] text-destructive">{oauth2Error}</p>1077 <div className="text-[11px] text-green-700 dark:text-green-400">
1221 </div>1078 Connected · {oauth2SelectedScopes.size} scope
1222 )}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>
1223 </div>1121 </div>
1224 )}1122 )}
1225 </section>1123 </section>
packages/plugins/openapi/src/sdk/preview-oauth2.test.ts+9−2
@@ -63,17 +63,24 @@ describe("previewSpec OAuth2 extraction", () => { · 62 unmodified lines
63 expect(flow.authorizationUrl).toBe("https://example.com/oauth/authorize");63 expect(flow.authorizationUrl).toBe("https://example.com/oauth/authorize");
64 expect(flow.tokenUrl).toBe("https://example.com/oauth/token");64 expect(flow.tokenUrl).toBe("https://example.com/oauth/token");
65 expect(Option.getOrElse(flow.refreshUrl, () => "")).toBe("https://example.com/oauth/refresh");65 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 });
67 70
68 // A preset should be generated for this flow.71 // A preset should be generated for this flow.
69 expect(preview.oauth2Presets).toHaveLength(1);72 expect(preview.oauth2Presets).toHaveLength(1);
73 expect(preview.headerPresets).toHaveLength(0);
70 const preset = preview.oauth2Presets[0]!;74 const preset = preview.oauth2Presets[0]!;
71 expect(preset.flow).toBe("authorizationCode");75 expect(preset.flow).toBe("authorizationCode");
72 expect(preset.securitySchemeName).toBe("oauth_app");76 expect(preset.securitySchemeName).toBe("oauth_app");
73 expect(preset.tokenUrl).toBe("https://example.com/oauth/token");77 expect(preset.tokenUrl).toBe("https://example.com/oauth/token");
74 expect(preset.label).toContain("Authorization Code");78 expect(preset.label).toContain("Authorization Code");
75 expect(preset.label).toContain("oauth_app");79 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 });
77 }),84 }),
78 );85 );
79 86
packages/plugins/openapi/src/sdk/preview.ts+16−2
@@ -274,16 +274,30 @@ const buildHeaderPresets = ( · 273 unmodified lines
274 labelParts.push(scheme.name);274 labelParts.push(scheme.name);
275 } else if (scheme.type === "apiKey") {275 } else if (scheme.type === "apiKey") {
276 labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`);276 labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`);
277 } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
278 return [];
277 } else {279 } else {
278 labelParts.push(scheme.name);280 labelParts.push(scheme.name);
279 }281 }
280 }282 }
281 283
282 if (Object.keys(headers).length === 0 && resolved.length > 0) {284 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 ];
284 }292 }
285 293
286 return [new HeaderPreset({ label: labelParts.join(" + "), headers, secretHeaders })];294 return [
295 new HeaderPreset({
296 label: labelParts.join(" + "),
297 headers,
298 secretHeaders,
299 }),
300 ];
287 });301 });
288};302};
289 303
packages/react/src/components/combobox.tsx+65−1
@@ -45,18 +45,23 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { · 44 unmodified lines
45 45
46function ComboboxInput({46function ComboboxInput({
47 className,47 className,
48 inputClassName,
48 children,49 children,
49 disabled = false,50 disabled = false,
50 showTrigger = true,51 showTrigger = true,
51 showClear = false,52 showClear = false,
52 ...props53 ...props
53}: ComboboxPrimitive.Input.Props & {54}: ComboboxPrimitive.Input.Props & {
55 inputClassName?: string;
54 showTrigger?: boolean;56 showTrigger?: boolean;
55 showClear?: boolean;57 showClear?: boolean;
56}) {58}) {
57 return (59 return (
58 <InputGroup className={cn("w-auto", className)}>60 <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 />
60 <InputGroupAddon align="inline-end">65 <InputGroupAddon align="inline-end">
61 {showTrigger && (66 {showTrigger && (
62 <InputGroupButton67 <InputGroupButton
@@ -257,6 +262,64 @@ function useComboboxAnchor() { · 194 unmodified lines
257 return React.useRef<HTMLDivElement | null>(null);262 return React.useRef<HTMLDivElement | null>(null);
258}263}
259 264
265export interface FreeformComboboxOption {
266 readonly value: string;
267 readonly label?: React.ReactNode;
268 readonly description?: React.ReactNode;
269}
270
271function 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
260export {323export {
261 Combobox,324 Combobox,
262 ComboboxInput,325 ComboboxInput,
@@ -273,5 +336,6 @@ export { · 10 unmodified lines
273 ComboboxChipsInput,336 ComboboxChipsInput,
274 ComboboxTrigger,337 ComboboxTrigger,
275 ComboboxValue,338 ComboboxValue,
339 FreeformCombobox,
276 useComboboxAnchor,340 useComboboxAnchor,
277};341};
packages/react/src/plugins/credential-target-scope.tsx+23−0
@@ -1,4 +1,5 @@
1import { useEffect, useMemo, useState } from "react";1import { useEffect, useMemo, useState } from "react";
2import type { ReactNode } from "react";
2 3
3import type { ScopeId } from "@executor-js/sdk";4import type { ScopeId } from "@executor-js/sdk";
4 5
@@ -131,3 +132,25 @@ export function CredentialTargetScopeSelector(props: { · 126 unmodified lines
131 </CardStack>132 </CardStack>
132 );133 );
133}134}
135
136export 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
108 namespaceHint ??108 namespaceHint ??
109 (namespaceReadOnly109 (namespaceReadOnly
110 ? "The namespace is part of the source's identity and cannot be changed."110 ? "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);
112 112
113 return (113 return (
114 <CardStack>114 <CardStack>
115 <CardStackContent className="border-t-0">115 <CardStackContent className="border-t-0">
116 <CardStackEntryField label={nameLabel}>116 <SourceIdentityFieldRows
117 <Input117 identity={identity}
118 value={identity.name}118 namePlaceholder={namePlaceholder}
119 onChange={(e) => identity.setName((e.target as HTMLInputElement).value)}119 namespacePlaceholder={namespacePlaceholder}
120 placeholder={namePlaceholder}120 nameLabel={nameLabel}
121 className="text-sm"121 namespaceHint={effectiveNamespaceHint}
122 />122 namespaceReadOnly={namespaceReadOnly}
123 </CardStackEntryField>123 />
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>
137 </CardStackContent>124 </CardStackContent>
138 </CardStack>125 </CardStack>
139 );126 );
140}127}
128
129export 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}