diff --git a/apps/app/src/components/plugin/PluginSettings.test.tsx b/apps/app/src/components/plugin/PluginSettings.test.tsx index 3845e9e5f8..6667436a6c 100644 --- a/apps/app/src/components/plugin/PluginSettings.test.tsx +++ b/apps/app/src/components/plugin/PluginSettings.test.tsx @@ -29,9 +29,18 @@ function jsonOk(body: unknown): Response { const SETTINGS_VIEW = { ok: true, schema: { - greeting: { type: "string", label: "Greeting" }, + greeting: { + type: "string", + label: "Greeting", + placeholder: "Enter a greeting", + }, enabled: { type: "boolean", label: "Enabled" }, - apiKey: { type: "string", label: "API key", secret: true }, + apiKey: { + type: "string", + label: "API key", + secret: true, + placeholder: "Paste an API key", + }, }, values: { greeting: "hello", enabled: true, apiKey: { set: false } }, }; @@ -66,11 +75,12 @@ describe("PluginSettingsForm", () => { "Greeting", )) as HTMLInputElement; expect(greeting.value).toBe("hello"); + expect(greeting.placeholder).toBe("Enter a greeting"); // Secrets are write-only: no value, only a set/not-set placeholder. const apiKey = screen.getByLabelText("API key") as HTMLInputElement; expect(apiKey.value).toBe(""); - expect(apiKey.placeholder).toBe("[not set]"); + expect(apiKey.placeholder).toBe("Paste an API key"); const save = screen.getByRole("button", { name: /save settings/i }); expect((save as HTMLButtonElement).disabled).toBe(true); @@ -125,6 +135,31 @@ describe("PluginSettingsForm", () => { values: { apiKey: "sk-123" }, }); }); + + it("keeps the set marker for configured secrets", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + jsonOk({ + ...SETTINGS_VIEW, + values: { + ...SETTINGS_VIEW.values, + apiKey: { set: true }, + }, + }), + ), + ), + ); + + const { wrapper } = createQueryClientTestHarness(); + render(, { wrapper }); + + const apiKey = (await screen.findByLabelText( + "API key", + )) as HTMLInputElement; + expect(apiKey.placeholder).toBe("[set]"); + }); }); function rowPlugin( diff --git a/apps/app/src/components/plugin/PluginSettings.tsx b/apps/app/src/components/plugin/PluginSettings.tsx index a5d91dd283..a9722f4970 100644 --- a/apps/app/src/components/plugin/PluginSettings.tsx +++ b/apps/app/src/components/plugin/PluginSettings.tsx @@ -184,12 +184,17 @@ function PluginSettingField({ : !isSecret && typeof storedValue === "string" ? storedValue : ""; + const placeholder = isSecret + ? secretIsSet + ? "[set]" + : (descriptor.placeholder ?? "[not set]") + : descriptor.placeholder; return ( onChange(event.target.value)} className="h-7 w-full text-xs sm:w-64" /> diff --git a/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx b/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx index 82e46516ef..6d88c5f25e 100644 --- a/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx +++ b/apps/mobile/src/screens/plugins/PluginSettingsForm.tsx @@ -191,9 +191,9 @@ function SettingField({ placeholder={ isSecret ? pluginSecretIsSet(storedValue) - ? "[set] — type to replace" - : "[not set]" - : undefined + ? "[set]" + : (descriptor.placeholder ?? "[not set]") + : descriptor.placeholder } autoCapitalize="none" editable={!disabled} diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index 0926a86288..be3112afcc 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -53,6 +53,7 @@ export type PluginSettingDescriptor = description?: string; /** Stored in a 0600 file under /plugins//secrets/, never in the db or sent to the frontend. */ secret?: true; + placeholder?: string; default?: string; } | { type: "boolean"; label: string; description?: string; default?: boolean } diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts index bb4fc85928..fffa38eb1d 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -100,6 +100,7 @@ const settingDescriptorSchema = z.discriminatedUnion("type", [ type: z.literal("string"), ...settingsBaseFields, secret: z.literal(true).optional(), + placeholder: z.string().min(1).optional(), default: z.string().optional(), }) .strict(), diff --git a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts index 05ac4bb468..f25d61903e 100644 --- a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts +++ b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts @@ -238,6 +238,23 @@ describe("settings", () => { }); }); + it("retains string input placeholders", () => { + const { bb, harness } = createFakePluginHost(); + bb.settings.define({ + token: { + type: "string", + label: "Token", + placeholder: "Paste a token", + }, + }); + + expect(harness.registrations.settingsDescriptors.token).toEqual({ + type: "string", + label: "Token", + placeholder: "Paste a token", + }); + }); + it("setSettings validates, fires onChange with next/prev, and skips no-op updates", async () => { const { bb, harness } = createFakePluginHost(); const handle = defineSettings(bb); @@ -279,6 +296,17 @@ describe("settings", () => { broken: { type: "select", label: "B", options: ["a"], default: "z" }, }), ).toThrow('default for setting "broken" must be one of its options'); + expect(() => + bb.settings.define({ + emptyPlaceholder: { + type: "string", + label: "Empty placeholder", + placeholder: "", + }, + }), + ).toThrow( + 'invalid descriptor for setting "emptyPlaceholder" (placeholder)', + ); }); }); diff --git a/packages/server-contract/src/api/plugins.ts b/packages/server-contract/src/api/plugins.ts index 9f3a69643f..615ed116d1 100644 --- a/packages/server-contract/src/api/plugins.ts +++ b/packages/server-contract/src/api/plugins.ts @@ -296,6 +296,7 @@ export const pluginSettingDescriptorSchema = z.discriminatedUnion("type", [ ...pluginSettingBaseSchema, type: z.literal("string"), secret: z.literal(true).optional(), + placeholder: z.string().min(1).optional(), default: z.string().optional(), }) .strict(),