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
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

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