| @@ -69,6 +69,16 @@ const substituteUrlVariables = (url: string, values: Record<string, string>): st · 68 unmodified lines |
| 69 | 69 | return out; |
| 70 | 70 | }; |
| 71 | 71 | |
| 72 | +/** |
| 73 | + * Stable placeholder connection id used when saving a source without |
| 74 | + * completing the OAuth flow. OpenApiSignInButton rewrites `oauth2.connectionId` |
| 75 | + * to a freshly minted id on first sign-in, so this only needs to be |
| 76 | + * deterministic per-namespace and distinct from any real connection id |
| 77 | + * (real ids are `openapi-oauth2-${uuid}`). |
| 78 | + */ |
| 79 | +const openApiOAuthConnectionId = (namespaceSlug: string): string => |
| 80 | + `openapi-oauth2-pending-${namespaceSlug || "default"}`; |
| 81 | + |
| 72 | 82 | /** |
| 73 | 83 | * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute, |
| 74 | 84 | * but some specs ship relative paths like `/api/rest/v1/oauth/token`. |
| @@ -270,8 +280,18 @@ export default function AddOpenApiSource(props: { · 195 unmodified lines |
| 270 | 280 | const selectedOAuth2Preset: OAuth2Preset | null = |
| 271 | 281 | strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; |
| 272 | 282 | |
| 283 | + // OAuth is "ready to save" without completing the flow as long as we have |
| 284 | + // enough metadata to run sign-in later: a preset, a client id secret, and — |
| 285 | + // for clientCredentials — a client secret. Each user signs in via |
| 286 | + // OpenApiSignInButton on the source detail page; innermost-wins scope |
| 287 | + // shadowing resolves tokens per-user at invoke time. |
| 288 | + const oauth2DeferrableReady = |
| 289 | + selectedOAuth2Preset !== null && |
| 290 | + oauth2ClientIdSecretId !== null && |
| 291 | + (selectedOAuth2Preset.flow !== "clientCredentials" || |
| 292 | + oauth2ClientSecretSecretId !== null); |
| 273 | 293 | const oauth2Ready = |
| 274 | | − strategy.kind !== "oauth2" || oauth2Auth !== null; |
| 294 | + strategy.kind !== "oauth2" || oauth2Auth !== null || oauth2DeferrableReady; |
| 275 | 295 | |
| 276 | 296 | const canAdd = |
| 277 | 297 | preview !== null && |
| @@ -523,6 +543,40 @@ export default function AddOpenApiSource(props: { · 245 unmodified lines |
| 523 | 543 | kind: "openapi", |
| 524 | 544 | url: resolvedBaseUrl || undefined, |
| 525 | 545 | }); |
| 546 | + // If the user picked oauth2 but didn't complete sign-in, persist a |
| 547 | + // deferred OAuth2Auth with a stable placeholder connectionId so the |
| 548 | + // source lands with its OAuth config intact. OpenApiSignInButton |
| 549 | + // rewrites the pointer once a user actually signs in. |
| 550 | + let oauth2ToSave: OAuth2Auth | null = oauth2Auth; |
| 551 | + if ( |
| 552 | + !oauth2ToSave && |
| 553 | + strategy.kind === "oauth2" && |
| 554 | + selectedOAuth2Preset && |
| 555 | + oauth2ClientIdSecretId |
| 556 | + ) { |
| 557 | + const tokenUrl = resolveOAuthUrl( |
| 558 | + selectedOAuth2Preset.tokenUrl, |
| 559 | + resolvedBaseUrl, |
| 560 | + ); |
| 561 | + const authorizationUrl = |
| 562 | + selectedOAuth2Preset.flow === "authorizationCode" |
| 563 | + ? resolveOAuthUrl( |
| 564 | + Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), |
| 565 | + resolvedBaseUrl, |
| 566 | + ) || null |
| 567 | + : null; |
| 568 | + oauth2ToSave = new OAuth2Auth({ |
| 569 | + kind: "oauth2", |
| 570 | + connectionId: openApiOAuthConnectionId(namespace), |
| 571 | + securitySchemeName: selectedOAuth2Preset.securitySchemeName, |
| 572 | + flow: selectedOAuth2Preset.flow, |
| 573 | + tokenUrl, |
| 574 | + authorizationUrl, |
| 575 | + clientIdSecretId: oauth2ClientIdSecretId, |
| 576 | + clientSecretSecretId: oauth2ClientSecretSecretId, |
| 577 | + scopes: [...oauth2SelectedScopes], |
| 578 | + }); |
| 579 | + } |
| 526 | 580 | try { |
| 527 | 581 | await doAdd({ |
| 528 | 582 | path: { scopeId }, |
| @@ -532,7 +586,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines |
| 532 | 586 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 533 | 587 | baseUrl: resolvedBaseUrl || undefined, |
| 534 | 588 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 535 | | − ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), |
| 589 | + ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}), |
| 536 | 590 | }, |
| 537 | 591 | reactivityKeys: sourceWriteKeys, |
| 538 | 592 | }); |
| @@ -960,14 +1014,20 @@ export default function AddOpenApiSource(props: { · 421 unmodified lines |
| 960 | 1014 | </Button> |
| 961 | 1015 | </div> |
| 962 | 1016 | ) : ( |
| 963 | | − <Button |
| 964 | | − variant="secondary" |
| 965 | | − onClick={handleConnectOAuth2} |
| 966 | | − disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} |
| 967 | | − className="w-full" |
| 968 | | − > |
| 969 | | − Connect via OAuth |
| 970 | | − </Button> |
| 1017 | + <div className="flex flex-col gap-1.5"> |
| 1018 | + <Button |
| 1019 | + variant="secondary" |
| 1020 | + onClick={handleConnectOAuth2} |
| 1021 | + disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} |
| 1022 | + className="w-full" |
| 1023 | + > |
| 1024 | + Connect via OAuth |
| 1025 | + </Button> |
| 1026 | + <p className="text-[11px] text-muted-foreground"> |
| 1027 | + Optional — you can save the source now and each user can sign |
| 1028 | + in from the source detail page later. |
| 1029 | + </p> |
| 1030 | + </div> |
| 971 | 1031 | )} |
| 972 | 1032 | |
| 973 | 1033 | {oauth2Error && ( |