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.",