| @@ -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"; |
| 31 | import { | 31 | import { |
| 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"; |
| 35 | import { | 35 | import { |
| @@ -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"; |
| 40 | import { | 40 | import { |
| 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"; |
| 45 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; | 45 | import { 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"; |
| | 53 | import { FreeformCombobox } from "@executor-js/react/components/combobox"; |
| 53 | import { | 54 | import { |
| 54 | CardStack, | 55 | CardStack, |
| 55 | CardStackContent, | 56 | CardStackContent, |
| @@ -63,12 +64,10 @@ import { FieldLabel } from "@executor-js/react/components/field"; · 7 unmodified lines |
| 63 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 64 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 64 | import { Input } from "@executor-js/react/components/input"; | 65 | import { Input } from "@executor-js/react/components/input"; |
| 65 | import { Label } from "@executor-js/react/components/label"; | 66 | import { Label } from "@executor-js/react/components/label"; |
| 66 | import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; | | |
| 67 | import { Textarea } from "@executor-js/react/components/textarea"; | 67 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 68 | import { Checkbox } from "@executor-js/react/components/checkbox"; | 68 | import { Checkbox } from "@executor-js/react/components/checkbox"; |
| 69 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | 69 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| 70 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; | 70 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 71 | import { Skeleton } from "@executor-js/react/components/skeleton"; | | |
| 72 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | 71 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 73 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; | 72 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; |
| 74 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; | 73 | import 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 | |
| 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 | | | |
| 108 | export const openApiOAuthConnectionId = ( | 99 | export 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 analysis | 218 | // 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 is | 420 | // No header presets — default to "custom" so the headers editor is |
| 423 | // visible immediately. Specs with no `security` block (e.g. Microsoft | 421 | // 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 > 0 | 634 | ...(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 | <CardStackEntryField | 759 | <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 | <Textarea | 792 | <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 radios | 884 | {/* 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 | <HeadersList | 962 | <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 | <HttpCredentialsEditor | 980 | <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 redirects | 997 | 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 | <CreatableSecretPicker | 1017 | 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} scope | 1033 | </span> |
| 1182 | {oauth2SelectedScopes.size === 1 ? "" : "s"} granted | 1034 | </FieldLabel> |
| 1183 | </div> | 1035 | <CreatableSecretPicker |
| 1184 | <Button variant="ghost" size="sm" onClick={() => setOauth2AuthState(null)}> | 1036 | value={oauth2ClientSecretSecretId} |
| 1185 | Disconnect | 1037 | 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> |