From c427cb5b6189f7c0e8ea3a09734440ad1d07f724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 04:00:04 +0000 Subject: [PATCH] plugins: a backend plugin can have settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin with no web/ has no route, no dock tile and no panel — so it had nowhere to put a setting. transcribe shipped exactly like that: installed, running, and configurable only by an HTTP call somebody had to already know about. The plugin DECLARES its fields and the platform renders them, on the plugin detail page — the one screen that exists for every plugin whether or not it has a frontend. A component would have survived settings too complicated to describe, at the cost of every backend plugin carrying a frontend to draw one text field, each looking slightly different. The platform stores nothing. It is a form over the plugin's own /config: GET to populate, PUT to save. So a plugin that validates before saving still does, and one that keeps a credential keeps it where it already kept it. A `secret` field is write-only across the boundary — never read back, rendered as a password, and left out of the PUT when untouched so an untyped box is not read as "clear it". The plugin reports whether it HAS one, never the value. Eight strings across all six locales, because the key type refuses anything else — which is the localisation work from earlier tonight doing its job. 1426 pass, tsgo clean, docs:check green. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/plugins/router.ts | 3 + src/servers/plugins/manifest.ts | 43 +++++ .../src/apps/Plugins/PluginDetail.tsx | 12 +- .../src/apps/Plugins/PluginSettings.tsx | 155 ++++++++++++++++++ .../officerdev/src/apps/Plugins/usePlugins.ts | 11 ++ .../officerdev/src/locales/de-DE.json | 10 +- .../officerdev/src/locales/en-GB.json | 10 +- .../officerdev/src/locales/es-ES.json | 10 +- .../officerdev/src/locales/fr-FR.json | 10 +- .../officerdev/src/locales/pt-PT.json | 10 +- .../officerdev/src/locales/sv-SE.json | 10 +- 11 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Plugins/PluginSettings.tsx diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 46448ff7..6da459f4 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -147,6 +147,9 @@ pluginsRouter.get('/', async (ctx) => { version: plugin.manifest.version, platform: plugin.manifest.platform, permissions: plugin.manifest.permissions, + // What the owner must be able to set. Declared rather than rendered by the plugin, so a backend + // plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`. + settings: plugin.manifest.settings ?? [], // What the tree declared. The UI shows these so "installed but does nothing" is legible. has: { api: !!plugin.api, diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index e619120f..584dd941 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -182,6 +182,22 @@ export type OsDependency = { }; }; +/** One field on a plugin's settings form. See `settings` on the manifest for why this is declared. */ +export type PluginSetting = { + /** The key in the JSON body sent to `/config`. */ + key: string; + /** What the owner sees beside the input. */ + label: string; + /** One line under it — what this is for, or where to find the value. Optional. */ + hint?: string; + /** `url` and `text` render as text inputs; `secret` is write-only and renders as a password. */ + type: 'url' | 'text' | 'secret'; + /** Refuse to save without it. */ + required?: boolean; + /** Shown greyed in an empty input, never submitted. */ + placeholder?: string; +}; + export type PluginManifest = { /** * Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two @@ -346,6 +362,33 @@ export type PluginManifest = { * The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a * request-forgery primitive reachable by anyone the plugin is granted to. */ + /** + * Settings the owner must be able to set, rendered by the platform on the plugin's own detail page. + * + * ── Why a declaration and not a component ── + * + * A plugin with no `web/` has nowhere to put a settings screen — no route, no dock tile, no panel. That + * is most backend plugins, and it left them configurable only by an HTTP call somebody had to know + * about. `transcribe` shipped in exactly that state: installed, running, and with no way to tell it + * where its server was. + * + * The plugin could ship a component instead, and that survives settings too complicated to describe. + * But every backend plugin would then carry a frontend to render one text field, and they would each + * look slightly different. A declaration means the platform renders one consistent form and the plugin + * ships no UI at all — which for a URL and a token is the whole requirement. + * + * ── The contract ── + * + * The platform GETs and PUTs `/config`, and the plugin owns what that means. Values are + * never stored by the platform: it is a form over the plugin's own route, so a plugin that keeps a + * credential keeps it, and a plugin that validates before saving still does. + * + * A field marked `secret` is write-only across the boundary — sent when set, never read back, and + * rendered as a password input. The plugin returns whether it HAS one, never the value, the same shape + * the Jellyfin access token already uses. + */ + settings?: PluginSetting[]; + websocket?: { sidecar: string }; osDependencies?: OsDependency[]; diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index 9090ed4f..f46fc819 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -17,6 +17,7 @@ import { type PluginVerb, } from './usePlugins'; import { useTranslation } from '../../locales'; +import { PluginSettings } from './PluginSettings'; // The right panel: one plugin, and its verbs — install, enable, disable, update, delete. // @@ -30,7 +31,7 @@ const Row = ({ label, children }: { label: string; children: React.ReactNode }) ); -const Button = ({ +export const Button = ({ onClick, disabled, tone = 'ghost', @@ -541,6 +542,15 @@ export const PluginDetail = () => { ) : null} + {/* A backend plugin has no screen of its own, so its settings live here — the one page that exists + for every plugin whether or not it has a frontend. Renders nothing when none are declared. */} + + {/* Disable and delete are different in kind, not in degree, so the screen says which is which diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginSettings.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginSettings.tsx new file mode 100644 index 00000000..208b72bd --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginSettings.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useClient } from 'hooks/useClient'; +import { Input } from '@/components/ui/input'; +import { useTranslation } from '../../locales'; +// The same button the actions beside this form use — a settings pane that looked like a different app +// would be its own small bug. +import { Button } from './PluginDetail'; + +/** + * The settings form for a plugin that has no screen of its own. + * + * ── Why this exists ── + * + * A backend plugin has no route, no dock tile and no panel, so until now it had nowhere to put a setting. + * `transcribe` shipped exactly like that: installed, running, and configurable only by an HTTP call + * somebody had to already know about. That is not a setting, it is a secret handshake. + * + * The plugin DECLARES its fields (`settings` on the manifest) and this renders them. It ships no frontend + * at all — which for a URL and a token is the whole requirement, and it means every backend plugin's + * settings look and behave the same instead of each inventing a form. + * + * ── The platform stores nothing ── + * + * This is a form over the plugin's OWN `/config` route. It GETs to populate and PUTs to save, and + * the values never touch a platform table. So a plugin that validates before saving still does, a plugin + * that keeps a credential keeps it where it already kept it, and this file never has to know what any of + * the fields mean. + */ + +type PluginSetting = { + key: string; + label: string; + hint?: string; + type: 'url' | 'text' | 'secret'; + required?: boolean; + placeholder?: string; +}; + +type PluginSettingsProps = { + appName: string; + /** `mountPrefix()` for this plugin — `/transcribe`, or `/p//` for a third party. */ + prefix: string; + settings: PluginSetting[]; + /** Nothing to configure on a plugin whose routes are not mounted. */ + enabled: boolean; +}; + +export const PluginSettings = ({ appName, prefix, settings, enabled }: PluginSettingsProps) => { + const client = useClient(); + const { t } = useTranslation(); + const qc = useQueryClient(); + const [values, setValues] = useState>({}); + + const { data: current, isLoading } = useQuery({ + queryKey: ['plugin-config', appName], + queryFn: () => client.get>(`/api${prefix}/config`), + enabled, + // A plugin that has no /config yet, or is mid-restart, is not an error worth a red toast on a screen + // the owner opened to read about the plugin. + retry: false, + }); + + /* + Populate once, from whatever the plugin returns. + + A `secret` is deliberately NOT populated even when the plugin reports having one — it is write-only + across this boundary, so there is nothing to put in the box. The placeholder says it is already set, + which is the honest rendering: leaving it blank keeps the stored value, typing replaces it. + */ + useEffect(() => { + if (!current) return; + const next: Record = {}; + for (const f of settings) { + if (f.type === 'secret') continue; + const v = current[f.key]; + if (typeof v === 'string') next[f.key] = v; + } + setValues((prev) => ({ ...next, ...prev })); + }, [current, settings]); + + const save = useMutation({ + mutationFn: () => { + // Only what was typed. An untouched secret must not be sent as an empty string, which the plugin + // would reasonably read as "clear it". + const body: Record = {}; + for (const f of settings) { + const v = values[f.key]; + if (v !== undefined && v !== '') body[f.key] = v; + } + return client.put(`/api${prefix}/config`, body); + }, + onSuccess: () => { + toast.success(t('plugins.settingsSaved')); + // The plugin may normalise what it stored — a trailing slash, a resolved host — so read it back + // rather than trusting what was typed. + void qc.invalidateQueries({ queryKey: ['plugin-config', appName] }); + setValues({}); + }, + onError: (err) => toast.error(err instanceof Error ? err.message : t('plugins.settingsFailed')), + }); + + if (!settings.length) return null; + + const missingRequired = settings.some((f) => { + if (!f.required) return false; + const typed = values[f.key]; + const stored = current?.[f.key]; + // Satisfied by something already stored, including a secret the plugin only reports the presence of. + return !typed && !stored; + }); + + return ( +
+

{t('plugins.settings')}

+

{t('plugins.settingsHint')}

+ + {!enabled ? ( +

{t('plugins.settingsDisabled')}

+ ) : ( +
+ {settings.map((f) => { + const hasStoredSecret = f.type === 'secret' && !!current?.[f.key]; + return ( + + ); + })} +
+ +
+
+ )} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index ecf8c73d..1b7c4846 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -7,6 +7,15 @@ import { useClient, getHeaders } from 'hooks/useClient'; // Not the app store. That installs sidecars from a compiled-in catalogue, provisioning containers and // asking questions; this installs plugins from the tree and asks nothing. +export type PluginSetting = { + key: string; + label: string; + hint?: string; + type: 'url' | 'text' | 'secret'; + required?: boolean; + placeholder?: string; +}; + export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean }; /** One host binary the plugin shells out to, probed against this machine's PATH on every read. */ @@ -55,6 +64,7 @@ export type PluginItem = { version: string; platform: string; permissions: PluginPermission[]; + settings: PluginSetting[]; /** What the directory declared. Shown so "installed but does nothing" is legible rather than puzzling. */ has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean }; installed: boolean; @@ -130,6 +140,7 @@ export type AvailablePlugin = { version: string; platform: string; permissions: PluginPermission[]; + settings: PluginSetting[]; has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean }; /** Where it will be cloned from. Shown, because an install that fetches code should say from where. */ source: string; diff --git a/src/workspaces/officerdev/src/locales/de-DE.json b/src/workspaces/officerdev/src/locales/de-DE.json index 4f5a7e52..ae35f870 100644 --- a/src/workspaces/officerdev/src/locales/de-DE.json +++ b/src/workspaces/officerdev/src/locales/de-DE.json @@ -198,7 +198,15 @@ "mountsAt": "Bindet ein unter", "sidecar": "Sidecar", "deleteNamed": "{{name}} löschen", - "updateTo": "Auf {{version}} aktualisieren" + "updateTo": "Auf {{version}} aktualisieren", + "settings": "Einstellungen", + "settingsHint": "Werden vom Plugin gespeichert, nie von der Plattform.", + "settingsDisabled": "Aktiviere das Plugin, um es zu konfigurieren.", + "settingsSave": "Speichern", + "settingsSaving": "Wird gespeichert…", + "settingsSaved": "Einstellungen gespeichert", + "settingsFailed": "Einstellungen konnten nicht gespeichert werden", + "settingsSecretSet": "Gesetzt — leer lassen, um es zu behalten" }, "commandHq": { "selectOne": "Wählen Sie einen Commander aus oder starten Sie einen neuen.", diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index ad583cc9..707f0995 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -198,7 +198,15 @@ "mountsAt": "Mounts at", "sidecar": "Sidecar", "deleteNamed": "Delete {{name}}", - "updateTo": "Update to {{version}}" + "updateTo": "Update to {{version}}", + "settings": "Settings", + "settingsHint": "Stored by the plugin, never by the platform.", + "settingsDisabled": "Enable the plugin to configure it.", + "settingsSave": "Save", + "settingsSaving": "Saving…", + "settingsSaved": "Settings saved", + "settingsFailed": "Could not save the settings", + "settingsSecretSet": "Set — leave blank to keep" }, "commandHq": { "selectOne": "Select a commander, or start a new one.", diff --git a/src/workspaces/officerdev/src/locales/es-ES.json b/src/workspaces/officerdev/src/locales/es-ES.json index 1a48d7af..189427b6 100644 --- a/src/workspaces/officerdev/src/locales/es-ES.json +++ b/src/workspaces/officerdev/src/locales/es-ES.json @@ -198,7 +198,15 @@ "mountsAt": "Se monta en", "sidecar": "Sidecar", "deleteNamed": "Eliminar {{name}}", - "updateTo": "Actualizar a {{version}}" + "updateTo": "Actualizar a {{version}}", + "settings": "Ajustes", + "settingsHint": "Los guarda el plugin, nunca la plataforma.", + "settingsDisabled": "Activa el plugin para configurarlo.", + "settingsSave": "Guardar", + "settingsSaving": "Guardando…", + "settingsSaved": "Ajustes guardados", + "settingsFailed": "No se han podido guardar los ajustes", + "settingsSecretSet": "Definido — déjalo vacío para mantenerlo" }, "commandHq": { "selectOne": "Selecciona un commander, o inicia uno nuevo.", diff --git a/src/workspaces/officerdev/src/locales/fr-FR.json b/src/workspaces/officerdev/src/locales/fr-FR.json index 422f93fe..fd26d296 100644 --- a/src/workspaces/officerdev/src/locales/fr-FR.json +++ b/src/workspaces/officerdev/src/locales/fr-FR.json @@ -198,7 +198,15 @@ "mountsAt": "Monté sur", "sidecar": "Sidecar", "deleteNamed": "Supprimer {{name}}", - "updateTo": "Mettre à jour vers {{version}}" + "updateTo": "Mettre à jour vers {{version}}", + "settings": "Paramètres", + "settingsHint": "Stockés par le plugin, jamais par la plateforme.", + "settingsDisabled": "Activez le plugin pour le configurer.", + "settingsSave": "Enregistrer", + "settingsSaving": "Enregistrement…", + "settingsSaved": "Paramètres enregistrés", + "settingsFailed": "Impossible d'enregistrer les paramètres", + "settingsSecretSet": "Défini — laissez vide pour le conserver" }, "commandHq": { "selectOne": "Sélectionnez un commander, ou démarrez-en un nouveau.", diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index 451b7878..a25d03f1 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -198,7 +198,15 @@ "mountsAt": "Monta em", "sidecar": "Sidecar", "deleteNamed": "Eliminar {{name}}", - "updateTo": "Atualizar para {{version}}" + "updateTo": "Atualizar para {{version}}", + "settings": "Definições", + "settingsHint": "Guardadas pelo plugin, nunca pela plataforma.", + "settingsDisabled": "Ative o plugin para o configurar.", + "settingsSave": "Guardar", + "settingsSaving": "A guardar…", + "settingsSaved": "Definições guardadas", + "settingsFailed": "Não foi possível guardar as definições", + "settingsSecretSet": "Definido — deixe em branco para manter" }, "commandHq": { "selectOne": "Selecione um comandante ou inicie um novo.", diff --git a/src/workspaces/officerdev/src/locales/sv-SE.json b/src/workspaces/officerdev/src/locales/sv-SE.json index 7de85aa3..4f624d09 100644 --- a/src/workspaces/officerdev/src/locales/sv-SE.json +++ b/src/workspaces/officerdev/src/locales/sv-SE.json @@ -198,7 +198,15 @@ "mountsAt": "Monteras på", "sidecar": "Sidovagn", "deleteNamed": "Ta bort {{name}}", - "updateTo": "Uppdatera till {{version}}" + "updateTo": "Uppdatera till {{version}}", + "settings": "Inställningar", + "settingsHint": "Sparas av tillägget, aldrig av plattformen.", + "settingsDisabled": "Aktivera tillägget för att konfigurera det.", + "settingsSave": "Spara", + "settingsSaving": "Sparar…", + "settingsSaved": "Inställningarna sparades", + "settingsFailed": "Det gick inte att spara inställningarna", + "settingsSecretSet": "Angivet — lämna tomt för att behålla" }, "commandHq": { "selectOne": "Välj en commander, eller starta en ny.",