diff --git a/apps/app/src/components/plugin/PluginSettings.test.tsx b/apps/app/src/components/plugin/PluginSettings.test.tsx index 68d1ddbdaa..0cafd407bf 100644 --- a/apps/app/src/components/plugin/PluginSettings.test.tsx +++ b/apps/app/src/components/plugin/PluginSettings.test.tsx @@ -97,6 +97,79 @@ describe("PluginSettingsForm", () => { }); }); + it("renders a multiline string in a textarea and saves embedded newlines", async () => { + const view = { + ok: true, + schema: { + preamble: { type: "string", label: "Preamble", multiline: true }, + }, + values: { preamble: "first\nsecond" }, + }; + const requests: RecordedRequest[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + return jsonOk(view); + }), + ); + + const { wrapper } = createQueryClientTestHarness(); + render(, { wrapper }); + + const preamble = (await screen.findByLabelText( + "Preamble", + )) as HTMLTextAreaElement; + expect(preamble.tagName).toBe("TEXTAREA"); + expect(preamble.value).toBe("first\nsecond"); + + fireEvent.change(preamble, { target: { value: "first\nsecond\nthird" } }); + fireEvent.click(screen.getByRole("button", { name: /save settings/i })); + + const put = await vi.waitFor(() => { + const found = requests.find((request) => request.init?.method === "PUT"); + expect(found).toBeDefined(); + return found; + }); + expect(JSON.parse(String(put?.init?.body))).toEqual({ + values: { preamble: "first\nsecond\nthird" }, + }); + }); + + it("renders a secret multiline setting write-only and unmasked", async () => { + const view = { + ok: true, + schema: { + signingKey: { + type: "string", + label: "Signing key", + secret: true, + multiline: true, + }, + }, + values: { signingKey: { set: true } }, + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonOk(view)), + ); + + const { wrapper } = createQueryClientTestHarness(); + render(, { wrapper }); + + const key = (await screen.findByLabelText( + "Signing key", + )) as HTMLTextAreaElement; + expect(key.tagName).toBe("TEXTAREA"); + // Write-only: the server sends `{ set }`, never the value. + expect(key.value).toBe(""); + expect(key.placeholder).toBe("[set]"); + // No password mode exists for a textarea, so browser leaks are closed off + // explicitly instead. + expect(key.getAttribute("spellcheck")).toBe("false"); + expect(key.getAttribute("autocomplete")).toBe("off"); + }); + it("never sends an untouched secret and includes a typed one", async () => { const requests: RecordedRequest[] = []; vi.stubGlobal( diff --git a/apps/app/src/components/plugin/PluginSettings.tsx b/apps/app/src/components/plugin/PluginSettings.tsx index 07f9ff064f..b5c4b2e6b8 100644 --- a/apps/app/src/components/plugin/PluginSettings.tsx +++ b/apps/app/src/components/plugin/PluginSettings.tsx @@ -13,6 +13,7 @@ import { Icon } from "@bb/shared-ui/icon"; import { Input } from "@bb/shared-ui/input"; import { SettingsWithControl } from "@/components/ui/settings-section.js"; import { Switch } from "@bb/shared-ui/switch"; +import { Textarea } from "@bb/shared-ui/textarea"; import { ResourceDetailPanel } from "@bb/shared-ui/resource-list"; import { applyPluginSettingsView } from "@/hooks/cache-owners/plugin-cache-owner"; import { @@ -177,6 +178,28 @@ function PluginSettingField({ : !isSecret && typeof storedValue === "string" ? storedValue : ""; + // A secret textarea is write-only like its input counterpart — the server + // only ever reports `{ set }`, so `value` is empty until the user types. + // It is deliberately not masked: there is no `type="password"` for a + // textarea, and masking a pasted PEM key would hide the one thing worth + // checking without protecting a value that is never rendered back. Browser + // spellcheck and autofill are turned off instead, so a pasted key is not + // shipped to a spellcheck service or offered as an autofill target. + if (descriptor.multiline === true) { + return ( +