Skip to content
Open
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
41 changes: 38 additions & 3 deletions apps/app/src/components/plugin/PluginSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(<PluginSettingsForm pluginId="demo" />, { wrapper });

const apiKey = (await screen.findByLabelText(
"API key",
)) as HTMLInputElement;
expect(apiKey.placeholder).toBe("[set]");
});
});

function rowPlugin(
Expand Down
7 changes: 6 additions & 1 deletion apps/app/src/components/plugin/PluginSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,17 @@ function PluginSettingField({
: !isSecret && typeof storedValue === "string"
? storedValue
: "";
const placeholder = isSecret
? secretIsSet
? "[set]"
: (descriptor.placeholder ?? "[not set]")
: descriptor.placeholder;
return (
<Input
type={isSecret ? "password" : "text"}
value={value}
aria-label={descriptor.label}
placeholder={isSecret ? (secretIsSet ? "[set]" : "[not set]") : undefined}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="h-7 w-full text-xs sm:w-64"
/>
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/screens/plugins/PluginSettingsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-sdk/src/backend-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type PluginSettingDescriptor =
description?: string;
/** Stored in a 0600 file under <dataDir>/plugins/<id>/secrets/, never in the db or sent to the frontend. */
secret?: true;
placeholder?: string;
default?: string;
}
| { type: "boolean"; label: string; description?: string; default?: boolean }
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-sdk/src/internal/host-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
28 changes: 28 additions & 0 deletions packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)',
);
});
});

Expand Down
1 change: 1 addition & 0 deletions packages/server-contract/src/api/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down