Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/app/src/components/plugin/PluginSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PluginSettingsForm pluginId="demo" />, { 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(<PluginSettingsForm pluginId="demo" />, { 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(
Expand Down
28 changes: 28 additions & 0 deletions apps/app/src/components/plugin/PluginSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<Textarea
value={value}
aria-label={descriptor.label}
placeholder={
isSecret ? (secretIsSet ? "[set]" : "[not set]") : undefined
}
spellCheck={isSecret ? false : undefined}
autoComplete={isSecret ? "off" : undefined}
onChange={(event) => onChange(event.target.value)}
className="min-h-24 w-full resize-y text-xs"
/>
);
}
return (
<Input
type={isSecret ? "password" : "text"}
Expand Down Expand Up @@ -242,6 +265,11 @@ export function PluginSettingsForm({ pluginId }: { pluginId: string }) {
? "secret"
: undefined
}
layout={
descriptor.type === "string" && descriptor.multiline === true
? "stacked"
: "inline"
}
{...(descriptor.description !== undefined
? { description: descriptor.description }
: {})}
Expand Down
19 changes: 15 additions & 4 deletions apps/app/src/components/ui/settings-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export interface SettingsWithControlProps {
label: string;
labelBadge?: string;
description?: string;
/**
* "stacked" puts the control on its own full-width row beneath the label.
* Use it for controls that need the width — a textarea is unreadable in the
* narrow right-hand column an inline row gives it. Defaults to "inline".
*/
layout?: "inline" | "stacked";
children: ReactNode;
}

Expand All @@ -85,16 +91,19 @@ export function SettingsWithControl({
label,
labelBadge,
description,
layout = "inline",
children,
}: SettingsWithControlProps) {
const stacked = layout === "stacked";
return (
<div
className={cn(
"flex flex-col gap-2.5 sm:flex-row sm:justify-between sm:gap-5",
description ? "sm:items-start" : "sm:items-center",
"flex flex-col gap-2.5",
!stacked && "sm:flex-row sm:justify-between sm:gap-5",
!stacked && (description ? "sm:items-start" : "sm:items-center"),
)}
>
<div className="min-w-0 flex-1">
<div className={cn("min-w-0", !stacked && "flex-1")}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<p className="min-w-0 text-sm font-normal text-foreground">{label}</p>
{labelBadge ? <SettingsBadge>{labelBadge}</SettingsBadge> : null}
Expand All @@ -105,7 +114,9 @@ export function SettingsWithControl({
</p>
) : null}
</div>
<div className="shrink-0 sm:flex sm:justify-end">{children}</div>
<div className={stacked ? "min-w-0" : "shrink-0 sm:flex sm:justify-end"}>
{children}
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion apps/server/src/services/plugins/plugin-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const descriptorSchema = z.discriminatedUnion("type", [
type: z.literal("string"),
...baseFields,
secret: z.literal(true).optional(),
multiline: z.literal(true).optional(),
default: z.string().optional(),
})
.strict(),
Expand Down Expand Up @@ -182,7 +183,8 @@ export async function readPluginSettingsValues(
) {
parsed = undefined;
}
values[key] = (parsed as PluginSettingValue | undefined) ?? descriptor.default;
values[key] =
(parsed as PluginSettingValue | undefined) ?? descriptor.default;
}
return values;
}
Expand Down
Loading
Loading