/** * Plugin Settings page * * Auto-generates a settings form from a plugin's `admin.settingsSchema`. * Values are stored server-side under the same KV keys the plugin reads * via `ctx.kv.get("settings:{key}")`. Secret fields are write-only: the * server never returns their values, only whether one is set. */ import { Button, Input, InputArea, Select, Switch, Toast } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { FloppyDisk } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { fetchPlugin, fetchPluginSettings, updatePluginSettings, type SettingField, } from "../lib/api/plugins.js"; import { ArrowPrev } from "./ArrowIcons.js"; import { EditorHeader } from "./EditorHeader"; import { RouterLinkButton } from "./RouterLinkButton.js"; export interface PluginSettingsProps { pluginId: string; } export function PluginSettings({ pluginId }: PluginSettingsProps) { const { t } = useLingui(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const { data: plugin } = useQuery({ queryKey: ["plugins", pluginId], queryFn: () => fetchPlugin(pluginId), }); const { data: settings, isLoading, error, } = useQuery({ queryKey: ["plugin-settings", pluginId], queryFn: () => fetchPluginSettings(pluginId), }); const [values, setValues] = React.useState>({}); // Secret fields are write-only: track typed input separately and only // send keys the user actually changed. const [secretInputs, setSecretInputs] = React.useState>({}); const [clearedSecrets, setClearedSecrets] = React.useState>(new Set()); React.useEffect(() => { if (settings) { setValues(settings.values); setSecretInputs({}); setClearedSecrets(new Set()); } }, [settings]); const saveMutation = useMutation({ mutationFn: (updates: Record) => updatePluginSettings(pluginId, updates), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["plugin-settings", pluginId] }); toastManager.add({ title: t`Settings saved successfully`, timeout: 3000, }); }, onError: (err) => { toastManager.add({ title: t`Failed to save settings`, description: err instanceof Error ? err.message : t`An error occurred`, type: "error", timeout: 5000, }); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!settings) return; const updates: Record = {}; for (const [key, field] of Object.entries(settings.schema)) { if (field.type === "secret") { if (clearedSecrets.has(key)) { updates[key] = null; } else if (secretInputs[key]) { updates[key] = secretInputs[key]; } continue; } const value = values[key]; // An emptied field reverts to the schema default: send null so // the server deletes the stored value. Sending "" would persist // an empty string and shadow the default; skipping the key would // leave the old stored value in place. Never-set fields also send // null — deleting a missing key is a no-op. const cleared = value === null || value === undefined || value === ""; updates[key] = cleared ? null : value; } saveMutation.mutate(updates); }; const setValue = (key: string, value: unknown) => { setValues((prev) => ({ ...prev, [key]: value })); }; const pluginName = plugin?.name ?? pluginId; const backLink = ( } /> ); if (isLoading) { return (
{backLink}

{t`${pluginName} Settings`}

{t`Loading settings...`}

); } if (error || !settings) { return (
{backLink}

{t`${pluginName} Settings`}

{error instanceof Error ? error.message : t`Failed to load plugin settings`}

); } const fields = Object.entries(settings.schema); return (
} > {saveMutation.isPending ? t`Saving...` : t`Save Settings`} } >

{t`${pluginName} Settings`}

{fields.length === 0 && (

{t`This plugin has no configurable settings.`}

)} {fields.map(([key, field]) => ( setValue(key, value)} onSecretChange={(text) => { setSecretInputs((prev) => ({ ...prev, [key]: text })); setClearedSecrets((prev) => { if (!prev.has(key)) return prev; const next = new Set(prev); next.delete(key); return next; }); }} onSecretClear={() => { setSecretInputs((prev) => ({ ...prev, [key]: "" })); setClearedSecrets((prev) => new Set(prev).add(key)); }} /> ))}
{fields.length > 0 && ( )}
); } interface SettingFieldInputProps { fieldKey: string; field: SettingField; value: unknown; secretSet: boolean; secretInput: string; secretCleared: boolean; onChange: (value: unknown) => void; onSecretChange: (text: string) => void; onSecretClear: () => void; } function SettingFieldInput({ fieldKey, field, value, secretSet, secretInput, secretCleared, onChange, onSecretChange, onSecretClear, }: SettingFieldInputProps) { const { t } = useLingui(); // Schema labels/descriptions are plugin-authored strings (like admin // page labels) — rendered as-is, not localized. switch (field.type) { case "string": return field.multiline ? ( onChange(e.target.value)} /> ) : ( onChange(e.target.value)} /> ); case "url": case "email": return ( onChange(e.target.value)} /> ); case "number": return ( { const parsed = Number.parseFloat(e.target.value); onChange(Number.isNaN(parsed) ? null : parsed); }} /> ); case "boolean": return (

{field.label}

{field.description &&

{field.description}

}
onChange(checked)} aria-label={field.label} />
); case "select": { // Kumo Select.items is a value->label record, not {value,label}[]. const items: Record = {}; for (const option of field.options) { items[option.value] = option.label; } return ( ); } case "secret": return (
onSecretChange(e.target.value)} /> {secretSet && !secretCleared && ( )}
); default: { const _exhaustive: never = field; return null; } } } export default PluginSettings;