Skip to content

Commit 438938d

Browse files
authored
Merge pull request #15 from contextforge-org/5721-mcp-oauth-form-quick-wins
fix: MCP server OAuth form quick wins (read-only redirect URI, drop password grant) UI part
2 parents ebc7dc3 + 29879dc commit 438938d

7 files changed

Lines changed: 221 additions & 139 deletions

File tree

src/components/mcp-servers/MCPServerForm.test.tsx

Lines changed: 10 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -793,111 +793,16 @@ describe("MCPServerForm", () => {
793793
});
794794
});
795795

796-
describe("OAuth Password Grant Validation", () => {
797-
// Helper: open advanced settings, switch to OAuth auth, select password grant
798-
const renderWithOAuthPassword = // pragma: allowlist secret
799-
async () => {
800-
const user = userEvent.setup();
801-
renderWithRouter(<MCPServerForm {...defaultProps} />);
802-
await user.click(screen.getByRole("button", { name: /Advanced settings/i }));
803-
await user.click(screen.getByRole("radio", { name: /OAuth 2\.0/i }));
804-
// Grant type defaults to client_credentials; switch to password
805-
await user.click(screen.getByRole("combobox", { name: /Grant type/i }));
806-
await user.click(screen.getByRole("option", { name: /Resource owner password/i }));
807-
return user;
808-
};
809-
810-
it("shows username and password fields when password grant is selected", async () => {
811-
await renderWithOAuthPassword();
812-
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
813-
expect(screen.getByLabelText(/^Password/i)).toBeInTheDocument();
814-
});
815-
816-
it("disables the submit button when username is empty", async () => {
817-
await renderWithOAuthPassword();
818-
fireEvent.change(screen.getByLabelText(/^Name/i), { target: { value: "Test Server" } });
819-
fireEvent.change(screen.getByLabelText(/^URL/i), {
820-
target: { value: "http://localhost:3000" },
821-
});
822-
// Leave username empty, fill password
823-
fireEvent.change(screen.getByLabelText(/^Password/i), { target: { value: "secret" } });
824-
expect(screen.getByRole("button", { name: /Connect server/i })).toBeDisabled();
825-
});
826-
827-
it("disables the submit button when password is empty", async () => {
828-
await renderWithOAuthPassword();
829-
fireEvent.change(screen.getByLabelText(/^Name/i), { target: { value: "Test Server" } });
830-
fireEvent.change(screen.getByLabelText(/^URL/i), {
831-
target: { value: "http://localhost:3000" },
832-
});
833-
fireEvent.change(screen.getByLabelText(/^Username/i), {
834-
target: { value: "service-account" },
835-
});
836-
// Leave password empty
837-
expect(screen.getByRole("button", { name: /Connect server/i })).toBeDisabled();
838-
});
839-
840-
it("enables the submit button when both username and password are provided", async () => {
841-
await renderWithOAuthPassword();
842-
fireEvent.change(screen.getByLabelText(/^Name/i), { target: { value: "Test Server" } });
843-
fireEvent.change(screen.getByLabelText(/^URL/i), {
844-
target: { value: "http://localhost:3000" },
845-
});
846-
fireEvent.change(screen.getByLabelText(/^Username/i), {
847-
target: { value: "service-account" },
848-
});
849-
fireEvent.change(screen.getByLabelText(/^Password/i), { target: { value: "secret" } });
850-
expect(screen.getByRole("button", { name: /Connect server/i })).not.toBeDisabled();
851-
});
852-
853-
it("marks username input as aria-invalid when username error is present", async () => {
854-
await renderWithOAuthPassword();
855-
fireEvent.change(screen.getByLabelText(/^Name/i), { target: { value: "Test Server" } });
856-
fireEvent.change(screen.getByLabelText(/^URL/i), {
857-
target: { value: "http://localhost:3000" },
858-
});
859-
// Only fill password, leave username empty
860-
fireEvent.change(screen.getByLabelText(/^Password/i), { target: { value: "secret" } });
861-
862-
// Expose the field without a value and attempt form submission
863-
const form = document.querySelector("form")!;
864-
fireEvent.submit(form);
865-
866-
await waitFor(() => {
867-
expect(screen.getByLabelText(/^Username/i)).toHaveAttribute("aria-invalid", "true");
868-
});
869-
});
870-
871-
it("marks password input as aria-invalid when password error is present", async () => {
872-
await renderWithOAuthPassword();
873-
fireEvent.change(screen.getByLabelText(/^Name/i), { target: { value: "Test Server" } });
874-
fireEvent.change(screen.getByLabelText(/^URL/i), {
875-
target: { value: "http://localhost:3000" },
876-
});
877-
fireEvent.change(screen.getByLabelText(/^Username/i), {
878-
target: { value: "service-account" },
879-
});
880-
// Leave password empty, submit the form
881-
const form = document.querySelector("form")!;
882-
fireEvent.submit(form);
883-
884-
await waitFor(() => {
885-
expect(screen.getByLabelText(/^Password/i)).toHaveAttribute("aria-invalid", "true");
886-
});
887-
});
888-
889-
it("shows inline error messages for both fields when both are empty", async () => {
890-
await renderWithOAuthPassword();
891-
fireEvent.change(screen.getByLabelText(/^Name/i), { target: { value: "Test Server" } });
892-
fireEvent.change(screen.getByLabelText(/^URL/i), {
893-
target: { value: "http://localhost:3000" },
894-
});
895-
fireEvent.submit(document.querySelector("form")!);
896-
897-
await waitFor(() => {
898-
expect(screen.getByText("Username is required for password grant")).toBeInTheDocument();
899-
expect(screen.getByText("Password is required for password grant")).toBeInTheDocument();
900-
});
796+
describe("OAuth Password Grant", () => {
797+
it("does not offer the deprecated password grant for new servers", async () => {
798+
const user = userEvent.setup();
799+
renderWithRouter(<MCPServerForm {...defaultProps} />);
800+
await user.click(screen.getByRole("button", { name: /Advanced settings/i }));
801+
await user.click(screen.getByRole("radio", { name: /OAuth 2\.0/i }));
802+
await user.click(screen.getByRole("combobox", { name: /Grant type/i }));
803+
expect(
804+
screen.queryByRole("option", { name: /Resource owner password/i }),
805+
).not.toBeInTheDocument();
901806
});
902807

903808
it("does not show password-grant errors when a different OAuth grant type is selected", async () => {

src/components/mcp-servers/MCPServerForm.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, type ReactNode } from "react";
1+
import { useCallback, useState, type ReactNode } from "react";
22
import { useIntl } from "react-intl";
33
import { ChevronDown, CircleAlert } from "lucide-react";
44
import { Button } from "@/components/ui/button";
@@ -96,6 +96,13 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
9696
setQueryParamApiKey,
9797
} = useMCPServerForm(serverId);
9898

99+
const handleRedirectUriChange = useCallback(
100+
(uri: string) => {
101+
setOAuthRedirectUri(uri);
102+
},
103+
[setOAuthRedirectUri],
104+
);
105+
99106
const handleCancel = () => {
100107
setCreatedGateway(null);
101108
onToggle();
@@ -347,7 +354,7 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
347354
onOAuthTokenUrlChange={setOAuthTokenUrl}
348355
onOAuthGrantTypeChange={setOAuthGrantType}
349356
onOAuthIssuerUrlChange={setOAuthIssuerUrl}
350-
onOAuthRedirectUriChange={setOAuthRedirectUri}
357+
onOAuthRedirectUriChange={handleRedirectUriChange}
351358
onOAuthAuthorizationUrlChange={setOAuthAuthorizationUrl}
352359
onOAuthScopesChange={setOAuthScopes}
353360
onOAuthStoreTokensChange={setOAuthStoreTokens}

src/components/mcp-servers/OAuth2Auth.test.tsx

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, it, expect, vi } from "vitest";
2-
import { screen, fireEvent } from "@testing-library/react";
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { screen, fireEvent, act } from "@testing-library/react";
33
import { renderWithProviders as render } from "@/test/test-utils";
44
import { OAuth2Auth } from "./OAuth2Auth";
55

@@ -145,30 +145,71 @@ describe("OAuth2Auth", () => {
145145
expect(onPasswordChange).toHaveBeenCalledWith("test-pass");
146146
});
147147

148-
it("should trigger callbacks for authorization_code fields", () => {
149-
const onRedirectUriChange = vi.fn();
148+
it("shows a read-only derived redirect URI, lifts it into form state, and triggers the authorization URL callback", () => {
150149
const onAuthorizationUrlChange = vi.fn();
150+
const onRedirectUriChange = vi.fn();
151151

152152
render(
153153
<OAuth2Auth
154154
{...defaultProps}
155155
grantType="authorization_code"
156-
onRedirectUriChange={onRedirectUriChange}
157156
onAuthorizationUrlChange={onAuthorizationUrlChange}
157+
onRedirectUriChange={onRedirectUriChange}
158158
/>,
159159
);
160160

161-
fireEvent.change(screen.getByLabelText(/Redirect URI/i), {
162-
target: { value: "https://redirect.com" },
163-
});
164-
expect(onRedirectUriChange).toHaveBeenCalledWith("https://redirect.com");
161+
const redirect = screen.getByLabelText(/Redirect URI/i);
162+
expect(redirect).toHaveAttribute("readonly");
163+
expect(redirect).toHaveValue(`${window.location.origin}/oauth/callback`);
164+
expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeInTheDocument();
165+
expect(onRedirectUriChange).toHaveBeenCalledWith(`${window.location.origin}/oauth/callback`);
165166

166167
fireEvent.change(screen.getByLabelText(/Authorization URL/i), {
167168
target: { value: "https://auth.com/authorize" },
168169
});
169170
expect(onAuthorizationUrlChange).toHaveBeenCalledWith("https://auth.com/authorize");
170171
});
171172

173+
it("displays a stored redirect URI verbatim without overwriting it", () => {
174+
const onRedirectUriChange = vi.fn();
175+
176+
render(
177+
<OAuth2Auth
178+
{...defaultProps}
179+
grantType="authorization_code"
180+
redirectUri="https://public.example.com/oauth/callback"
181+
onRedirectUriChange={onRedirectUriChange}
182+
/>,
183+
);
184+
185+
expect(screen.getByLabelText(/Redirect URI/i)).toHaveValue(
186+
"https://public.example.com/oauth/callback",
187+
);
188+
expect(onRedirectUriChange).not.toHaveBeenCalled();
189+
});
190+
191+
it("does not set a redirect URI for non-authorization_code grants", () => {
192+
const onRedirectUriChange = vi.fn();
193+
194+
render(
195+
<OAuth2Auth
196+
{...defaultProps}
197+
grantType="client_credentials"
198+
onRedirectUriChange={onRedirectUriChange}
199+
/>,
200+
);
201+
202+
expect(onRedirectUriChange).not.toHaveBeenCalled();
203+
});
204+
205+
it("only offers the password grant option when already selected (legacy)", () => {
206+
const { rerender } = render(<OAuth2Auth {...defaultProps} grantType="client_credentials" />);
207+
expect(screen.queryByText(/Password grant is deprecated/i)).not.toBeInTheDocument();
208+
209+
rerender(<OAuth2Auth {...defaultProps} grantType="password" />);
210+
expect(screen.getByText(/Password grant is deprecated/i)).toBeInTheDocument();
211+
});
212+
172213
it("should trigger checkbox callback functions", () => {
173214
const onStoreTokensChange = vi.fn();
174215
const onAutoRefreshChange = vi.fn();
@@ -187,4 +228,84 @@ describe("OAuth2Auth", () => {
187228
fireEvent.click(screen.getByLabelText(/Automatically refresh expired tokens/i));
188229
expect(onAutoRefreshChange).toHaveBeenCalled();
189230
});
231+
232+
describe("copy button interaction", () => {
233+
beforeEach(() => {
234+
Object.assign(navigator, {
235+
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
236+
});
237+
});
238+
239+
it("copies the redirect URI to clipboard when the copy button is clicked", async () => {
240+
render(<OAuth2Auth {...defaultProps} grantType="authorization_code" />);
241+
242+
const copyButton = screen.getByRole("button", { name: /Copy to clipboard/i });
243+
await act(async () => {
244+
fireEvent.click(copyButton);
245+
});
246+
247+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
248+
`${window.location.origin}/oauth/callback`,
249+
);
250+
});
251+
252+
it("shows a check icon immediately after clicking copy and reverts after 2 s", async () => {
253+
vi.useFakeTimers();
254+
255+
render(<OAuth2Auth {...defaultProps} grantType="authorization_code" />);
256+
257+
const copyButton = screen.getByRole("button", { name: /Copy to clipboard/i });
258+
await act(async () => {
259+
fireEvent.click(copyButton);
260+
});
261+
262+
// The button is still present (aria-label unchanged; icon swap is visual-only)
263+
expect(copyButton).toBeInTheDocument();
264+
265+
await act(async () => {
266+
vi.advanceTimersByTime(2000);
267+
});
268+
269+
vi.useRealTimers();
270+
});
271+
});
272+
273+
describe("localhost warning", () => {
274+
it("shows a localhost warning when the derived redirect URI points to localhost", () => {
275+
// jsdom sets window.location.origin to 'http://localhost'
276+
render(<OAuth2Auth {...defaultProps} grantType="authorization_code" />);
277+
278+
expect(
279+
screen.getByText(/Redirect URIs derived from localhost will not work/i),
280+
).toBeInTheDocument();
281+
});
282+
283+
it("does not show the localhost warning when a non-localhost stored redirect URI is used", () => {
284+
render(
285+
<OAuth2Auth
286+
{...defaultProps}
287+
grantType="authorization_code"
288+
redirectUri="https://public.example.com/oauth/callback"
289+
/>,
290+
);
291+
292+
expect(
293+
screen.queryByText(/Redirect URIs derived from localhost will not work/i),
294+
).not.toBeInTheDocument();
295+
});
296+
297+
it("shows the localhost warning when a stored redirect URI points to 127.0.0.1", () => {
298+
render(
299+
<OAuth2Auth
300+
{...defaultProps}
301+
grantType="authorization_code"
302+
redirectUri="http://127.0.0.1:8080/oauth/callback"
303+
/>,
304+
);
305+
306+
expect(
307+
screen.getByText(/Redirect URIs derived from localhost will not work/i),
308+
).toBeInTheDocument();
309+
});
310+
});
190311
});

0 commit comments

Comments
 (0)