fix(ui): bake password-manager autofill guard into PasswordInput

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:47:46 +02:00
1 parent dbc2b15b1b
commit 9f4c7e48fe
2 files changed
+58

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("PasswordInput", () => {
it("suppresses password managers by default for config secrets", () => {
const html = render(<PasswordInput id="ntfy-token" />);
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(<PasswordInput id="tg-token" autoComplete="off" />);
expect(html).toContain("data-bwignore");
expect(html).toContain("data-1p-ignore");
});
it("keeps manager integration for credential fields", () => {
const html = render(
<PasswordInput id="current-password" autoComplete="current-password" />,
);
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");
});
});
+14
View File
@@ -22,14 +22,28 @@ import { useTranslations } from "@/lib/i18n/context";
*/
export function PasswordInput({
className,
autoComplete,
...props
}: React.ComponentProps<typeof Input>) {
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 (
<div className="relative">
<Input
{...props}
autoComplete={autoComplete ?? "off"}
data-bwignore={suppressManagers ? "" : undefined}
data-1p-ignore={suppressManagers ? "" : undefined}
data-lpignore={suppressManagers ? "true" : undefined}
type={visible ? "text" : "password"}
className={cn("pr-12", className)}
/>