From 9f4c7e48fe5efe316cdfcabef8c4dbba2acd3fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:47:46 +0200 Subject: [PATCH] fix(ui): bake password-manager autofill guard into PasswordInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no autocomplete token, so PasswordInput now defaults to autoComplete=off plus data-bwignore / data-1p-ignore / data-lpignore for them — Bitwarden / 1Password / LastPass no longer offer to fill or save a config secret. Credential fields (login, register, password change) pass a real token and keep manager integration untouched. One change covers every secret call site. --- .../ui/__tests__/password-input.test.tsx | 44 +++++++++++++++++++ src/components/ui/password-input.tsx | 14 ++++++ 2 files changed, 58 insertions(+) create mode 100644 src/components/ui/__tests__/password-input.test.tsx diff --git a/src/components/ui/__tests__/password-input.test.tsx b/src/components/ui/__tests__/password-input.test.tsx new file mode 100644 index 000000000..dadb1389d --- /dev/null +++ b/src/components/ui/__tests__/password-input.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { PasswordInput } from "../password-input"; + +/** + * PasswordInput bakes the password-manager autofill guard in by default so + * BYOK API keys / webhook secrets / ntfy tokens (config secrets, not + * credentials) are never offered up to Bitwarden / 1Password / LastPass. + * Credential fields that pass a real autocomplete token keep manager + * integration. + */ +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("PasswordInput", () => { + it("suppresses password managers by default for config secrets", () => { + const html = render(); + expect(html).toMatch(/autocomplete="off"/i); + expect(html).toContain("data-bwignore"); + expect(html).toContain("data-1p-ignore"); + expect(html).toContain('data-lpignore="true"'); + }); + + it("treats an explicit autoComplete=off as a config secret too", () => { + const html = render(); + expect(html).toContain("data-bwignore"); + expect(html).toContain("data-1p-ignore"); + }); + + it("keeps manager integration for credential fields", () => { + const html = render( + , + ); + expect(html).toMatch(/autocomplete="current-password"/i); + expect(html).not.toContain("data-bwignore"); + expect(html).not.toContain("data-1p-ignore"); + expect(html).not.toContain("data-lpignore"); + }); +}); diff --git a/src/components/ui/password-input.tsx b/src/components/ui/password-input.tsx index 7e1ac7625..e9149c3ce 100644 --- a/src/components/ui/password-input.tsx +++ b/src/components/ui/password-input.tsx @@ -22,14 +22,28 @@ import { useTranslations } from "@/lib/i18n/context"; */ export function PasswordInput({ className, + autoComplete, ...props }: React.ComponentProps) { const [visible, setVisible] = useState(false); const { t } = useTranslations(); + + // Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no + // `autoComplete` — they are not credentials, so a password manager offering + // to fill or *save* them is noise and risks clobbering the field. Suppress + // Bitwarden / 1Password / LastPass by default for those. Credential fields + // (login, register, password change) pass a real token + // (`current-password` / `new-password` / `username`) and keep manager + // integration; only an explicit `"off"` opts back into suppression. + const suppressManagers = autoComplete == null || autoComplete === "off"; return (