diff --git a/changelog.d/fixes/8883-proxy-credential-autofill.md b/changelog.d/fixes/8883-proxy-credential-autofill.md new file mode 100644 index 0000000000..71d03dbe78 --- /dev/null +++ b/changelog.d/fixes/8883-proxy-credential-autofill.md @@ -0,0 +1 @@ +- **fix(proxy):** isolate new proxy credential fields from browser and password-manager autofill after form reset ([#8883](https://github.com/diegosouzapw/OmniRoute/pull/8883)) — thanks @xiaoyaner0201 diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index e5ce799907..b87aa200d8 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -1014,6 +1014,9 @@ export default function ProxyRegistryManager({ setForm((prev) => ({ ...prev, username: e.target.value }))} /> @@ -1024,6 +1027,9 @@ export default function ProxyRegistryManager({ type="password" className="w-full px-3 py-2 rounded bg-bg-subtle border border-border" value={form.password} + autoComplete="new-password" + data-1p-ignore="true" + data-lpignore="true" placeholder={editingId ? t("passwordPlaceholderEdit") : ""} onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))} /> diff --git a/tests/unit/ui/ProxyRegistryManager-credential-autofill.test.tsx b/tests/unit/ui/ProxyRegistryManager-credential-autofill.test.tsx new file mode 100644 index 0000000000..5e4268deee --- /dev/null +++ b/tests/unit/ui/ProxyRegistryManager-credential-autofill.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const translate = (key: string) => key; + +vi.mock("next-intl", () => ({ + useTranslations: () => translate, +})); + +const SEEDED_PROXY = { + id: "proxy-8855", + name: "Seeded proxy", + type: "http", + host: "127.0.0.1", + port: 8080, + username: "stored-user", + password: "stored-password", + status: "active", + family: "auto", +}; + +let root: Root; +let container: HTMLDivElement; +let postBody: Record | undefined; + +function jsonResponse(body: unknown): Response { + return { ok: true, json: async () => body } as Response; +} + +function findButton(text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes(text) + ); + if (!button) throw new Error(`Button not found: ${text}`); + return button; +} + +function findCredentialInput(label: string): HTMLInputElement { + const labelNode = Array.from(container.querySelectorAll("label")).find( + (candidate) => candidate.textContent?.trim() === label + ); + const input = labelNode?.parentElement?.querySelector("input"); + if (!input) throw new Error(`Credential input not found: ${label}`); + return input; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + if (!setter) throw new Error("HTMLInputElement value setter is unavailable"); + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function click(element: HTMLElement) { + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +async function waitFor(assertion: () => void, timeoutMs = 2000) { + const startedAt = Date.now(); + let lastError: unknown; + while (Date.now() - startedAt <= timeoutMs) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + } + } + throw lastError; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + postBody = undefined; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/api/settings/proxies" && init?.method === "POST") { + postBody = JSON.parse(String(init.body)); + return jsonResponse({ item: { ...SEEDED_PROXY, ...postBody } }); + } + if (url === "/api/settings/proxies") { + return jsonResponse({ items: [SEEDED_PROXY] }); + } + if (url.startsWith("/api/settings/proxies/health")) { + return jsonResponse({ items: [] }); + } + if (url.startsWith("/api/settings/proxies/assignments")) { + return jsonResponse({ items: [] }); + } + throw new Error(`Unexpected fetch: ${url}`); + }) + ); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("ProxyRegistryManager credential autofill regression #8855", () => { + it("keeps Edit → close → Add credentials blank and isolates both fields from autofill", async () => { + const { default: ProxyRegistryManager } = + await import("@/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager"); + + await act(async () => { + root.render(); + }); + await waitFor(() => expect(container.textContent).toContain(SEEDED_PROXY.name)); + + await click(findButton("edit")); + const editUsername = findCredentialInput("labelUsername"); + const editPassword = findCredentialInput("labelPassword"); + expect(editUsername.value).toBe(""); + expect(editPassword.value).toBe(""); + + setInputValue(editUsername, "edit-user-sentinel"); + setInputValue(editPassword, "edit-password-sentinel"); + await click(container.querySelector('button[aria-label="close"]')!); + await click( + container.querySelector('[data-testid="proxy-registry-open-create"]')! + ); + + const createUsername = findCredentialInput("labelUsername"); + const createPassword = findCredentialInput("labelPassword"); + expect(createUsername.value).toBe(""); + expect(createPassword.value).toBe(""); + + expect.soft(createUsername.getAttribute("autocomplete")).toBe("off"); + expect.soft(createPassword.getAttribute("autocomplete")).toBe("new-password"); + for (const input of [createUsername, createPassword]) { + expect.soft(input.getAttribute("data-1p-ignore")).toBe("true"); + expect.soft(input.getAttribute("data-lpignore")).toBe("true"); + } + + setInputValue( + container.querySelector('[data-testid="proxy-registry-name-input"]')!, + "New proxy" + ); + setInputValue( + container.querySelector('[data-testid="proxy-registry-host-input"]')!, + "proxy.example.test" + ); + await click(findButton("save")); + await waitFor(() => expect(postBody).toBeDefined()); + + expect([undefined, ""]).toContain(postBody?.username); + expect([undefined, ""]).toContain(postBody?.password); + expect(postBody?.username).not.toBe("edit-user-sentinel"); + expect(postBody?.password).not.toBe("edit-password-sentinel"); + }); +});