From 864be4108d8ff7368de985833d58c16c8cfe819b Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:06:20 +0800 Subject: [PATCH] Let a token-only provider save a base URL change --- .../ai-code-space/connect-remote-modal.tsx | 17 +++++++---------- .../components/connect-remote-modal.test.tsx | 11 +++++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx b/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx index 9bc722099..98c9aa641 100644 --- a/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx +++ b/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx @@ -1115,24 +1115,21 @@ function EditProviderStep({ instance, initialBaseUrl, onBack, onClose, onSaved } const defaults = useProviderDefaults(instance.provider); const effectiveCallbackUrl = defaults.data?.oAuthCallbackUrl ?? `${window.location.origin}/api/credentials/oauth/callback`; - // Two distinct validity rules depending on the provider's existing state: - // • Configuring a non-OAuth row (oauthEnabled=false) → BOTH client_id and secret are - // required to save. We're filling in the missing pair, not patching one of them. - // • Editing an OAuth-ready row → both blank is fine (means "keep existing"). Filling - // in just one is ambiguous (changing client_id without rotating the secret usually - // breaks token exchange) so we require both-or-neither here too. + // One rule: the OAuth client pair is all-or-nothing, whether this row already has one or not — a client_id + // without its secret can't exchange a code, and a secret without an id is meaningless. const clientIdFilled = clientId.trim().length > 0; const secretFilled = clientSecret.length > 0; const oauthPairIncomplete = clientIdFilled !== secretFilled; - const configuringFresh = !instance.oauthEnabled; - const missingRequiredForConfig = configuringFresh && (!clientIdFilled || !secretFilled); + // The default scopes go with a newly configured OAuth app, so they are sent for a row that is getting one now. + // A token-only row keeps none: the form says "fill in the OAuth client ID and secret", not "that is required". + const configuringFresh = !instance.oauthEnabled && clientIdFilled; const blanksOnImmutable = !displayName.trim() || !baseUrl.trim(); // A stored OAuth secret is only ever sent to the host it was entered for: moving the host needs it typed again. const nextBaseUrl = baseUrl.trim().replace(/\/$/, ""); const hostMoves = providerEndpointMoves(instance.baseUrl, nextBaseUrl); const secretMustFollow = providerSecretMustBeReentered(instance, nextBaseUrl); const missingSecretForMove = secretMustFollow && !secretFilled; - const submitDisabled = update.isPending || blanksOnImmutable || oauthPairIncomplete || missingRequiredForConfig || missingSecretForMove; + const submitDisabled = update.isPending || blanksOnImmutable || oauthPairIncomplete || missingSecretForMove; const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -1145,7 +1142,7 @@ function EditProviderStep({ instance, initialBaseUrl, onBack, onClose, onSaved } if (nextBaseUrl !== instance.baseUrl) patch.baseUrl = nextBaseUrl; if (clientIdFilled) patch.oauthClientId = clientId.trim(); if (secretFilled) patch.oauthClientSecret = clientSecret; - // If we're fixing a non-OAuth provider (no existing client_id), also send default scopes. + // A row gaining an OAuth app now also gets the default scope set; one that had none keeps none. if (configuringFresh && clientIdFilled && defaults.data?.defaultOAuthScopes) { patch.oauthDefaultScopes = defaults.data.defaultOAuthScopes; } diff --git a/frontend/src/components/connect-remote-modal.test.tsx b/frontend/src/components/connect-remote-modal.test.tsx index 7f8102d86..7862f7c65 100644 --- a/frontend/src/components/connect-remote-modal.test.tsx +++ b/frontend/src/components/connect-remote-modal.test.tsx @@ -1110,6 +1110,17 @@ describe("edit provider base URL", () => { expect((screen.getByRole("button", { name: /Save/ }) as HTMLButtonElement).disabled).toBe(false); }); + it("saves a base URL change on a token-only provider without an OAuth client pair", async () => { + const patches = renderEditing({ id: "pi1", teamId: "t1", provider: "GitLab", displayName: "Acme GitLab", baseUrl: "https://gitlab.acme.test", createdDate: "2026-01-01T00:00:00Z", oauthEnabled: false }); + await openEditSettings(); + + fireEvent.change(screen.getByDisplayValue("https://gitlab.acme.test"), { target: { value: "https://gitlab.moved.test" } }); + fireEvent.click(screen.getByRole("button", { name: /Save/ })); + + await waitFor(() => expect(patches.length).toBe(1)); + expect(patches[0]).toEqual({ baseUrl: "https://gitlab.moved.test" }); + }); + it("never sends the masked base URL back when only the name changes", async () => { const patches = renderEditing({ id: "pi1", teamId: "t1", provider: "GitLab", displayName: "Acme GitLab", baseUrl: "https://***:***@gitlab.acme.test", createdDate: "2026-01-01T00:00:00Z", oauthEnabled: true }); await openEditSettings();