| @@ -29,7 +29,7 @@ import { · 28 unmodified lines |
| 29 | 29 | type OAuthCompletionPayload, |
| 30 | 30 | } from "@executor-js/react/plugins/oauth-sign-in"; |
| 31 | 31 | import { |
| 32 | | − CredentialTargetScopeSelector, |
| 32 | + CredentialScopeSection, |
| 33 | 33 | useCredentialTargetScope, |
| 34 | 34 | } from "@executor-js/react/plugins/credential-target-scope"; |
| 35 | 35 | import { |
| @@ -39,7 +39,7 @@ import { · 3 unmodified lines |
| 39 | 39 | } from "@executor-js/react/plugins/secret-header-auth"; |
| 40 | 40 | import { |
| 41 | 41 | slugifyNamespace, |
| 42 | | − SourceIdentityFields, |
| 42 | + SourceIdentityFieldRows, |
| 43 | 43 | useSourceIdentity, |
| 44 | 44 | } from "@executor-js/react/plugins/source-identity"; |
| 45 | 45 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| @@ -50,6 +50,7 @@ import { · 4 unmodified lines |
| 50 | 50 | CollapsibleContent, |
| 51 | 51 | CollapsibleTrigger, |
| 52 | 52 | } from "@executor-js/react/components/collapsible"; |
| 53 | +import { FreeformCombobox } from "@executor-js/react/components/combobox"; |
| 53 | 54 | import { |
| 54 | 55 | CardStack, |
| 55 | 56 | CardStackContent, |
| @@ -63,12 +64,10 @@ import { FieldLabel } from "@executor-js/react/components/field"; · 7 unmodified lines |
| 63 | 64 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 64 | 65 | import { Input } from "@executor-js/react/components/input"; |
| 65 | 66 | import { Label } from "@executor-js/react/components/label"; |
| 66 | | −import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; |
| 67 | 67 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 68 | 68 | import { Checkbox } from "@executor-js/react/components/checkbox"; |
| 69 | 69 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| 70 | 70 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 71 | | −import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 72 | 71 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 73 | 72 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; |
| 74 | 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 | 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 | 99 | export const openApiOAuthConnectionId = ( |
| 109 | 100 | namespaceSlug: string, |
| 110 | 101 | flow: OAuth2Preset["flow"], |
| @@ -169,10 +160,16 @@ const parseStrategy = (value: string): StrategySelection => { · 58 unmodified lines |
| 169 | 160 | if (value === "none") return { kind: "none" }; |
| 170 | 161 | if (value === "custom") return { kind: "custom" }; |
| 171 | 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 | 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 | 174 | return { kind: "none" }; |
| 178 | 175 | }; |
| @@ -220,11 +217,7 @@ export default function AddOpenApiSource(props: { · 41 unmodified lines |
| 220 | 217 | |
| 221 | 218 | // After analysis |
| 222 | 219 | 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(""); |
| 228 | 221 | const identity = useSourceIdentity({ |
| 229 | 222 | fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", |
| 230 | 223 | fallbackNamespace: props.initialNamespace, |
| @@ -260,9 +253,13 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines |
| 260 | 253 | const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = |
| 261 | 254 | useCredentialTargetScope(); |
| 262 | 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 | 259 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); |
| 265 | | − const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" }); |
| 260 | + const doSetBinding = useAtomSet(setOpenApiSourceBinding, { |
| 261 | + mode: "promiseExit", |
| 262 | + }); |
| 266 | 263 | const secretList = useSecretPickerSecrets(); |
| 267 | 264 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 268 | 265 | popupName: OPENAPI_OAUTH_POPUP_NAME, |
| @@ -289,32 +286,37 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines |
| 289 | 286 | |
| 290 | 287 | // ---- Derived state ---- |
| 291 | 288 | |
| 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) => { |
| 309 | 290 | const vars: Record<string, ServerVariable> = Option.getOrElse( |
| 310 | 291 | server.variables, |
| 311 | 292 | () => ({}) as Record<string, ServerVariable>, |
| 312 | 293 | ); |
| 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 | + })); |
| 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 | 320 | const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {}; |
| 319 | 321 | const headerBindings: Array<{ slot: string; secretId: string }> = []; |
| 320 | 322 | for (const ch of customHeaders) { |
| @@ -404,20 +406,16 @@ export default function AddOpenApiSource(props: { · 83 unmodified lines |
| 404 | 406 | setPreview(result); |
| 405 | 407 | |
| 406 | 408 | 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 ?? "") : ""); |
| 416 | 410 | |
| 417 | 411 | const firstPreset = result.headerPresets[0]; |
| 418 | 412 | if (firstPreset) { |
| 419 | 413 | setStrategy({ kind: "header", presetIndex: 0 }); |
| 420 | 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 | 419 | } else { |
| 422 | 420 | // No header presets — default to "custom" so the headers editor is |
| 423 | 421 | // visible immediately. Specs with no `security` block (e.g. Microsoft |
| @@ -634,7 +632,9 @@ export default function AddOpenApiSource(props: { · 210 unmodified lines |
| 634 | 632 | baseUrl: resolvedBaseUrl || undefined, |
| 635 | 633 | ...(hasHeaders ? { headers: configuredHeaders } : {}), |
| 636 | 634 | ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0 |
| 637 | | − ? { queryParams: serializeHttpCredentials(runtimeCredentials).queryParams } |
| 635 | + ? { |
| 636 | + queryParams: serializeHttpCredentials(runtimeCredentials).queryParams, |
| 637 | + } |
| 638 | 638 | : {}), |
| 639 | 639 | ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}), |
| 640 | 640 | }, |
| @@ -753,70 +753,64 @@ export default function AddOpenApiSource(props: { · 112 unmodified lines |
| 753 | 753 | </p> |
| 754 | 754 | </div> |
| 755 | 755 | |
| 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" |
| 762 | 790 | > |
| 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", |
| 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 && ( |
| 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) ── */} |
| 820 | 814 | {preview ? ( |
| 821 | 815 | <CardStack> |
| 822 | 816 | <CardStackContent className="border-t-0"> |
| @@ -836,18 +830,42 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines |
| 836 | 830 | </CardStackEntryDescription> |
| 837 | 831 | </CardStackEntryContent> |
| 838 | 832 | </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> |
| 851 | 869 | </CardStackContent> |
| 852 | 870 | </CardStack> |
| 853 | 871 | ) : null} |
| @@ -861,147 +879,6 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines |
| 861 | 879 | {/* ── Everything below appears after analysis ── */} |
| 862 | 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 | 882 | <section className="space-y-2.5"> |
| 1006 | 883 | <FieldLabel>Authentication</FieldLabel> |
| 1007 | 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 | 960 | {/* Header-based auth input */} |
| 1084 | 961 | {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> |
| 1092 | 978 | )} |
| 1093 | 979 | |
| 1094 | 980 | <HttpCredentialsEditor |
| @@ -1104,122 +990,134 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines |
| 1104 | 990 | {/* OAuth2 configuration */} |
| 1105 | 991 | {selectedOAuth2Preset && ( |
| 1106 | 992 | <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> |
| 1117 | 1013 | </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 | + /> |
| 1175 | 1027 | </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 | + /> |
| 1187 | 1046 | </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 | + )} |
| 1193 | 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 | 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 && ( |
| 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> |
| 1223 | 1121 | </div> |
| 1224 | 1122 | )} |
| 1225 | 1123 | </section> |