| @@ -69,6 +69,16 @@ const substituteUrlVariables = (url: string, values: Record<string, string>): st · 68 unmodified lines |
| 69 | return out; | 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 | * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute, | 83 | * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute, |
| 74 | * but some specs ship relative paths like `/api/rest/v1/oauth/token`. | 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 | const selectedOAuth2Preset: OAuth2Preset | null = | 280 | const selectedOAuth2Preset: OAuth2Preset | null = |
| 271 | strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; | 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 | const oauth2Ready = | 293 | const oauth2Ready = |
| 274 | strategy.kind !== "oauth2" || oauth2Auth !== null; | 294 | strategy.kind !== "oauth2" || oauth2Auth !== null || oauth2DeferrableReady; |
| 275 | | 295 | |
| 276 | const canAdd = | 296 | const canAdd = |
| 277 | preview !== null && | 297 | preview !== null && |
| @@ -523,6 +543,40 @@ export default function AddOpenApiSource(props: { · 245 unmodified lines |
| 523 | kind: "openapi", | 543 | kind: "openapi", |
| 524 | url: resolvedBaseUrl || undefined, | 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 | try { | 580 | try { |
| 527 | await doAdd({ | 581 | await doAdd({ |
| 528 | path: { scopeId }, | 582 | path: { scopeId }, |
| @@ -532,7 +586,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines |
| 532 | namespace: slugifyNamespace(identity.namespace) || undefined, | 586 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 533 | baseUrl: resolvedBaseUrl || undefined, | 587 | baseUrl: resolvedBaseUrl || undefined, |
| 534 | ...(hasHeaders ? { headers: allHeaders } : {}), | 588 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 535 | ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), | 589 | ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}), |
| 536 | }, | 590 | }, |
| 537 | reactivityKeys: sourceWriteKeys, | 591 | reactivityKeys: sourceWriteKeys, |
| 538 | }); | 592 | }); |
| @@ -960,14 +1014,20 @@ export default function AddOpenApiSource(props: { · 421 unmodified lines |
| 960 | </Button> | 1014 | </Button> |
| 961 | </div> | 1015 | </div> |
| 962 | ) : ( | 1016 | ) : ( |
| 963 | <Button | 1017 | <div className="flex flex-col gap-1.5"> |
| 964 | variant="secondary" | 1018 | <Button |
| 965 | onClick={handleConnectOAuth2} | 1019 | variant="secondary" |
| 966 | disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} | 1020 | onClick={handleConnectOAuth2} |
| 967 | className="w-full" | 1021 | disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0} |
| 968 | > | 1022 | className="w-full" |
| 969 | Connect via OAuth | 1023 | > |
| 970 | </Button> | 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 | {oauth2Error && ( | 1033 | {oauth2Error && ( |