History

rework source add screens

Rhys Sullivan committed 6 months ago

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

Showing the changes to packages/plugins/openapi/src (3 of 9 files). Show all

Unified · Split

3 files
  1. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +280 −382
  2. packages/plugins/openapi/src/sdk/preview-oauth2.test.ts +9 −2
  3. packages/plugins/openapi/src/sdk/preview.ts +16 −2
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

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t