Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions frontend/src/_imported/ai-code-space/connect-remote-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/connect-remote-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading