plugins: a backend plugin can have settings
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 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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 })
|
||||
</div>
|
||||
);
|
||||
|
||||
const Button = ({
|
||||
export const Button = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tone = 'ghost',
|
||||
@@ -541,6 +542,15 @@ export const PluginDetail = () => {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<PluginSettings
|
||||
appName={plugin.appName}
|
||||
prefix={plugin.prefix}
|
||||
settings={plugin.settings ?? []}
|
||||
enabled={plugin.enabled}
|
||||
/>
|
||||
|
||||
<ActionLog steps={steps} result={result} running={running} />
|
||||
|
||||
{/* Disable and delete are different in kind, not in degree, so the screen says which is which
|
||||
|
||||
@@ -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 `<prefix>/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/<publisher>/<name>` 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<Record<string, string>>({});
|
||||
|
||||
const { data: current, isLoading } = useQuery({
|
||||
queryKey: ['plugin-config', appName],
|
||||
queryFn: () => client.get<Record<string, unknown>>(`/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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<div className="mt-6 border-t border-border pt-4">
|
||||
<h3 className="mb-1 text-sm font-semibold">{t('plugins.settings')}</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">{t('plugins.settingsHint')}</p>
|
||||
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.settingsDisabled')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{settings.map((f) => {
|
||||
const hasStoredSecret = f.type === 'secret' && !!current?.[f.key];
|
||||
return (
|
||||
<label key={f.key} className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
{f.label}
|
||||
{f.required ? <span className="text-destructive"> *</span> : null}
|
||||
</span>
|
||||
<Input
|
||||
type={f.type === 'secret' ? 'password' : 'text'}
|
||||
value={values[f.key] ?? ''}
|
||||
disabled={isLoading || save.isPending}
|
||||
placeholder={hasStoredSecret ? t('plugins.settingsSecretSet') : (f.placeholder ?? '')}
|
||||
onChange={(ev) => setValues((prev) => ({ ...prev, [f.key]: ev.target.value }))}
|
||||
/>
|
||||
{f.hint ? <span className="text-[11px] text-muted-foreground">{f.hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button
|
||||
tone="primary"
|
||||
disabled={save.isPending || isLoading || missingRequired || Object.keys(values).length === 0}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? t('plugins.settingsSaving') : t('plugins.settingsSave')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user