From 6e319b4f6f91a10645423fb9273c6a08e5ee9f62 Mon Sep 17 00:00:00 2001 From: JamesDAdams Date: Fri, 28 Aug 2026 17:14:54 +0200 Subject: [PATCH] feat(plugins): add plugin settings modal and registration contract --- README.md | 48 +++ src/provider/index.ts | 35 ++ src/server/llm/proxy.test.ts | 9 + src/server/providers/plugins/loader.ts | 8 + src/server/providers/plugins/registry.ts | 15 + src/server/routes/plugins.test.ts | 102 ++++++ src/server/routes/plugins.ts | 194 +++++++++- .../settings/PluginSettingsModal.test.tsx | 234 ++++++++++++ .../settings/PluginSettingsModal.tsx | 340 ++++++++++++++++++ .../settings/tabs/PluginsTab.test.tsx | 39 +- .../components/settings/tabs/PluginsTab.tsx | 48 ++- web/src/lib/pluginSettings.ts | 24 ++ 12 files changed, 1082 insertions(+), 14 deletions(-) create mode 100644 web/src/components/settings/PluginSettingsModal.test.tsx create mode 100644 web/src/components/settings/PluginSettingsModal.tsx create mode 100644 web/src/lib/pluginSettings.ts diff --git a/README.md b/README.md index ab200ab15..07d2cec08 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,54 @@ Restart OpenFox after installing or updating a plugin. - To use free OpenRouter models, you can install the [`openfox-openrouter-free`](https://github.com/JamesDAdams/openfox-openrouter-free) plugin. - To use free OpenCode models, you can install the [`openfox-opencode-free`](https://github.com/JamesDAdams/openfox-opencode-free) plugin. +### Plugin Settings + +Plugins can expose custom configuration settings in the OpenFox UI by calling `registry.registerSettings` during plugin initialization or specifying `"hasSettings": true` under the `openfox` key in `package.json`: + +```typescript +import type { ProviderPluginRegistry } from 'openfox' + +export function register(registry: ProviderPluginRegistry) { + registry.registerSettings({ + title: 'My Plugin Settings', + description: 'Configure API options', + fields: [ + { + key: 'apiKey', + label: 'API Key', + type: 'password', // 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea' + required: true, + }, + ], + }) +} +``` + +In `package.json`: + +```json +{ + "name": "my-openfox-plugin", + "version": "1.0.0", + "openfox": { + "apiVersion": 1, + "plugin": "dist/index.js", + "hasSettings": true + } +} +``` + +Or for plugins rendering a custom UI: + +```typescript +export function register(registry: ProviderPluginRegistry) { + registry.registerSettings({ + title: 'Custom Plugin Settings', + customUiUrl: 'http://localhost:3000/plugin-settings-ui', + }) +} +``` + ## Screenshots _Homepage — Project overview and session history_ diff --git a/src/provider/index.ts b/src/provider/index.ts index d846b161f..254dc0284 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -97,6 +97,37 @@ export interface ProviderPluginRuntime { readonly configDirectory: string } +// ============================================================================ +// Plugin Settings Types +// ============================================================================ + +export type PluginSettingFieldType = 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea' + +export interface PluginSettingOption { + label: string + value: string +} + +export interface PluginSettingField { + key: string + label: string + type: PluginSettingFieldType + description?: string + defaultValue?: string | number | boolean + options?: PluginSettingOption[] + placeholder?: string + required?: boolean +} + +export interface PluginSettingsSpec { + title?: string + description?: string + fields?: PluginSettingField[] + customUiUrl?: string + getSettings?: () => Promise> | Record + saveSettings?: (values: Record) => Promise | void +} + // ============================================================================ // Plugin Registry (passed to plugins during registration) // ============================================================================ @@ -105,6 +136,8 @@ export interface ProviderPluginRegistry { registerAuth(adapter: ProviderAuthAdapter): void registerTransport(adapter: ProviderTransportAdapter): void registerPreset(preset: ProviderPreset): void + registerSettings(spec: PluginSettingsSpec): void + registerSettingsForPlugin(packageName: string, spec: PluginSettingsSpec): void readonly runtime: ProviderPluginRuntime } @@ -138,6 +171,8 @@ export interface ProviderPluginManifest { name: string /** Semver version string. */ version: string + /** Whether the plugin provides a settings page / configuration. */ + hasSettings?: boolean /** Auth adapters this plugin provides. */ authAdapters: PluginAuthDescriptor[] /** Transport adapters this plugin provides. */ diff --git a/src/server/llm/proxy.test.ts b/src/server/llm/proxy.test.ts index 15a1b0db7..3c2bad819 100644 --- a/src/server/llm/proxy.test.ts +++ b/src/server/llm/proxy.test.ts @@ -36,11 +36,18 @@ vi.mock('undici', () => { } }) +const { mockNativeFetch } = vi.hoisted(() => { + const fn = vi.fn().mockImplementation(() => Promise.resolve(new Response('native-ok'))) + globalThis.fetch = fn + return { mockNativeFetch: fn } +}) + import { __resetProxyCache } from './proxy.js' describe('global fetch override', () => { beforeEach(() => { vi.clearAllMocks() + mockNativeFetch.mockImplementation(() => Promise.resolve(new Response('native-ok'))) mockProxyAgentInstances.length = 0 __resetProxyCache() }) @@ -51,6 +58,7 @@ describe('global fetch override', () => { const result = await fetch('http://example.com') expect(result).toBeInstanceOf(Response) + expect(mockNativeFetch).toHaveBeenCalledWith('http://example.com', undefined) expect(mockUndiciFetch).not.toHaveBeenCalled() }) @@ -60,6 +68,7 @@ describe('global fetch override', () => { const result = await fetch('http://example.com') expect(result).toBeInstanceOf(Response) + expect(mockNativeFetch).toHaveBeenCalledWith('http://example.com', undefined) expect(mockUndiciFetch).not.toHaveBeenCalled() }) diff --git a/src/server/providers/plugins/loader.ts b/src/server/providers/plugins/loader.ts index 4984a9f6c..baf5152f1 100644 --- a/src/server/providers/plugins/loader.ts +++ b/src/server/providers/plugins/loader.ts @@ -14,6 +14,7 @@ export interface ProviderPluginDiagnostic { version?: string source: string loaded: boolean + hasSettings?: boolean authAdapters: string[] transportAdapters: string[] presets: string[] @@ -106,6 +107,13 @@ export async function loadProviderPlugins(options: { options.registry.registerPreset(preset) diagnostic.presets.push(preset.id) }, + registerSettings(spec) { + diagnostic.hasSettings = true + options.registry.registerSettingsForPlugin(packageName, spec) + }, + registerSettingsForPlugin(packageName, spec) { + options.registry.registerSettingsForPlugin(packageName, spec) + }, } try { const module = (await import(pathToFileURL(join(packageDir, plugin)).href)) as { diff --git a/src/server/providers/plugins/registry.ts b/src/server/providers/plugins/registry.ts index 5a5bf8bb0..dd8e96497 100644 --- a/src/server/providers/plugins/registry.ts +++ b/src/server/providers/plugins/registry.ts @@ -1,5 +1,6 @@ import type { Provider } from '../../../shared/types.js' import type { + PluginSettingsSpec, ProviderAuthAdapter, ProviderPluginRegistry, ProviderPluginRuntime, @@ -11,6 +12,7 @@ export class ProviderRegistry implements ProviderPluginRegistry { private readonly authAdapters = new Map() private readonly transportAdapters = new Map() private readonly presets = new Map() + private readonly pluginSettingsSpecs = new Map() constructor(readonly runtime: ProviderPluginRuntime) {} @@ -26,6 +28,19 @@ export class ProviderRegistry implements ProviderPluginRegistry { this.register(this.presets, preset.id, preset, 'preset') } + registerSettings(spec: PluginSettingsSpec): void { + // Note: registerSettings can be bound to a specific plugin when registered via trackingRegistry + this.registerSettingsForPlugin('_global', spec) + } + + registerSettingsForPlugin(packageName: string, spec: PluginSettingsSpec): void { + this.pluginSettingsSpecs.set(packageName, spec) + } + + getPluginSettingsSpec(packageName: string): PluginSettingsSpec | undefined { + return this.pluginSettingsSpecs.get(packageName) + } + getAuth(id?: string): ProviderAuthAdapter | undefined { return id ? this.authAdapters.get(id) : undefined } diff --git a/src/server/routes/plugins.test.ts b/src/server/routes/plugins.test.ts index 701142db1..3597e021a 100644 --- a/src/server/routes/plugins.test.ts +++ b/src/server/routes/plugins.test.ts @@ -6,6 +6,18 @@ import { tmpdir } from 'node:os' import { createPluginRoutes } from './plugins.js' import { ProviderRegistry } from '../providers/plugins/registry.js' import type { ProviderPluginDiagnostic } from '../providers/plugins/index.js' +import { closeDatabase, initDatabase } from '../db/index.js' +import { loadConfig } from '../config.js' +import { SETTINGS_KEYS, setSetting } from '../db/settings.js' + +let mockConfigDir = '' +vi.mock('../../cli/paths.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getGlobalConfigDir: () => mockConfigDir || actual.getGlobalConfigDir('test'), + } +}) function createApp(options?: Partial[0]>) { const app = express() @@ -37,7 +49,12 @@ describe('plugin routes', () => { let baseUrl: string beforeEach(async () => { + closeDatabase() + const cfg = loadConfig() + cfg.database.path = ':memory:' + initDatabase(cfg) rootDir = await mkdtemp(join(tmpdir(), 'openfox-plugins-')) + mockConfigDir = rootDir const { app } = createApp({ config: { mode: 'test', providers: [] } as any, }) @@ -121,6 +138,91 @@ describe('plugin routes', () => { }) }) + describe('GET /:name/settings and POST /:name/settings', () => { + it('returns settings spec and saved values', async () => { + const appWithSpec = createApp({ + config: { mode: 'test', providers: [] } as any, + }) + appWithSpec.providerAdapters.registerSettingsForPlugin('test-plugin', { + title: 'Test Plugin Settings', + description: 'Configure test plugin options', + fields: [ + { key: 'apiKey', label: 'API Key', type: 'password', required: true }, + { key: 'enableFeature', label: 'Enable Feature', type: 'boolean', defaultValue: true }, + ], + }) + const lServer = appWithSpec.app.listen(0) + const lUrl = `http://localhost:${(lServer.address() as { port: number }).port}` + + try { + const resGet1 = await fetch(`${lUrl}/api/plugins/test-plugin/settings`) + expect(resGet1.status).toBe(200) + const bodyGet1 = (await resGet1.json()) as { + name: string + hasSpec: boolean + spec: any + values: Record + } + expect(bodyGet1.hasSpec).toBe(true) + expect(bodyGet1.spec.title).toBe('Test Plugin Settings') + expect(bodyGet1.values['enableFeature']).toBe(true) + + const resPost = await fetch(`${lUrl}/api/plugins/test-plugin/settings`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ values: { apiKey: 'secret123', enableFeature: false } }), + }) + expect(resPost.status).toBe(200) + const bodyPost = (await resPost.json()) as { success: boolean; values: Record } + expect(bodyPost.success).toBe(true) + expect(bodyPost.values).toEqual({ apiKey: 'secret123', enableFeature: false }) + + const resGet2 = await fetch(`${lUrl}/api/plugins/test-plugin/settings`) + const bodyGet2 = (await resGet2.json()) as { values: Record; configuredKeys: string[] } + // password field must not be echoed back on GET + expect(bodyGet2.values).toEqual({ enableFeature: false }) + expect(bodyGet2.configuredKeys).toEqual(['apiKey']) + + // Updating other settings without re-sending password should succeed + const resPost2 = await fetch(`${lUrl}/api/plugins/test-plugin/settings`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ values: { enableFeature: true } }), + }) + expect(resPost2.status).toBe(200) + } finally { + lServer.close() + } + }) + + it('returns translated error message when required field is missing in French locale', async () => { + setSetting(SETTINGS_KEYS.DISPLAY_LOCALE, 'fr') + const appWithSpec = createApp({ + config: { mode: 'test', providers: [] } as any, + }) + appWithSpec.providerAdapters.registerSettingsForPlugin('test-fr-plugin', { + title: 'Settings FR', + fields: [{ key: 'apiKey', label: 'Clé API', type: 'password', required: true }], + }) + const lServer = appWithSpec.app.listen(0) + const lUrl = `http://localhost:${(lServer.address() as { port: number }).port}` + + try { + const resPost = await fetch(`${lUrl}/api/plugins/test-fr-plugin/settings`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ values: {} }), + }) + expect(resPost.status).toBe(400) + const body = (await resPost.json()) as { error: string } + expect(body.error).toBe('Clé API est requis') + } finally { + setSetting(SETTINGS_KEYS.DISPLAY_LOCALE, 'en') + lServer.close() + } + }) + }) + describe('DELETE /:name', () => { it('rejects plugin name with dots', async () => { const res = await fetch(`${baseUrl}/api/plugins/my.plugin`, { diff --git a/src/server/routes/plugins.ts b/src/server/routes/plugins.ts index 625b025c9..2ca2f103c 100644 --- a/src/server/routes/plugins.ts +++ b/src/server/routes/plugins.ts @@ -12,6 +12,7 @@ import { isDirectoryEntry } from '../utils/fs.js' import type { ProviderRegistry } from '../providers/plugins/registry.js' import type { Config } from '../../shared/types.js' import { serverT } from '../i18n.js' +import { getSetting, setSetting } from '../db/settings.js' interface Logger { debug: (message: string, context?: Record) => void @@ -24,6 +25,29 @@ import { openFolder } from '../utils/openFolder.js' const execFileP = promisify(execFile) +function readPluginSettings( + name: string, + spec: ReturnType, +): Record { + const raw = getSetting(`plugin_settings:${name}`) + if (raw) { + try { + return JSON.parse(raw) as Record + } catch { + return {} + } + } + const defaults: Record = {} + if (spec?.fields) { + for (const field of spec.fields) { + if (field.defaultValue !== undefined) { + defaults[field.key] = field.defaultValue + } + } + } + return defaults +} + async function openFolderRoute( dir: string, res: { @@ -50,6 +74,14 @@ export function createPluginRoutes(options: { let registryCache: { data: unknown; ts: number } | null = null + router.param('name', (_req, res, next, name) => { + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + res.status(400).json({ error: serverT({ en: 'Invalid plugin name', fr: 'Nom de plugin invalide' }) }) + return + } + next() + }) + router.get('/registry', async (_req, res) => { try { const now = Date.now() @@ -135,11 +167,13 @@ export function createPluginRoutes(options: { logger.error('Plugin build failed', { repoName, error: String(err) }) } + let hasSettings = false if (!loadError) { try { const manifest = JSON.parse(await readFile(join(targetDir, 'package.json'), 'utf8')) const pluginEntry = manifest.openfox?.plugin as string | undefined const apiVersion = manifest.openfox?.apiVersion as number | undefined + if (manifest.openfox?.hasSettings) hasSettings = true if (!pluginEntry || !manifest.name) { loadError = serverT({ @@ -187,6 +221,15 @@ export function createPluginRoutes(options: { providerAdapters.registerPreset(preset) diagnostic.presets.push(preset.id) }, + registerSettings(spec) { + diagnostic.hasSettings = true + hasSettings = true + providerAdapters.registerSettingsForPlugin(manifest.name, spec) + }, + registerSettingsForPlugin(packageName, spec) { + hasSettings = true + providerAdapters.registerSettingsForPlugin(packageName, spec) + }, } await mod.register(trackingRegistry) diagnostic.loaded = true @@ -204,7 +247,7 @@ export function createPluginRoutes(options: { } } - res.json({ success: true, loaded, loadError, path: targetDir }) + res.json({ success: true, loaded, loadError, path: targetDir, hasSettings }) } catch (err) { await rm(tmpDir, { recursive: true, force: true }) const msg = err instanceof Error ? err.message : serverT({ en: 'Clone failed', fr: 'Échec du clonage' }) @@ -217,18 +260,23 @@ export function createPluginRoutes(options: { const pluginsDir = join(getGlobalConfigDir(config.mode ?? 'production'), 'plugins') try { const entries = await readdir(pluginsDir, { withFileTypes: true }) - const installed: { name: string; version: string | null }[] = [] + const installed: { name: string; version: string | null; hasSettings: boolean }[] = [] for (const entry of entries) { if (!(await isDirectoryEntry(pluginsDir, entry))) continue const pkgPath = join(pluginsDir, entry.name, 'package.json') let version: string | null = null + let hasSettingsInPkg = false try { const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) version = (pkg.version as string) ?? null + hasSettingsInPkg = Boolean(pkg.openfox?.hasSettings) } catch { // ignore if package.json not found or invalid } - installed.push({ name: entry.name, version }) + const hasRegisteredSettings = Boolean(providerAdapters.getPluginSettingsSpec(entry.name)) + const diag = pluginDiagnostics.find((d) => d.packageName === entry.name) + const hasSettings = hasSettingsInPkg || hasRegisteredSettings || Boolean(diag?.hasSettings) + installed.push({ name: entry.name, version, hasSettings }) } res.json({ installed }) } catch { @@ -236,6 +284,141 @@ export function createPluginRoutes(options: { } }) + router.get('/:name/settings', async (req, res) => { + const name = req.params.name as string + const spec = providerAdapters.getPluginSettingsSpec(name) + let values: Record = {} + + if (spec?.getSettings) { + try { + values = (await spec.getSettings()) ?? {} + } catch (err) { + logger.error('Failed to get plugin settings from plugin callback', { name, error: String(err) }) + } + } else { + values = readPluginSettings(name, spec) + } + + const clientSpec = spec + ? { + title: spec.title, + description: spec.description, + fields: spec.fields, + customUiUrl: spec.customUiUrl, + } + : null + + // Never echo password-type values back to the client. They are stored server-side + // and only updated on POST when the client sends a non-empty value. + const safeValues: Record = { ...values } + const configuredKeys: string[] = [] + if (spec?.fields) { + for (const field of spec.fields) { + if (field.type === 'password') { + if (values[field.key]) { + configuredKeys.push(field.key) + } + delete safeValues[field.key] + } + } + } + + res.json({ + name, + hasSpec: Boolean(spec), + spec: clientSpec, + values: safeValues, + configuredKeys, + }) + }) + + router.post('/:name/settings', async (req, res) => { + const name = req.params.name as string + const { values } = req.body as { values?: Record } + if (!values || typeof values !== 'object') { + return res + .status(400) + .json({ error: serverT({ en: 'values object is required', fr: 'L’objet values est requis' }) }) + } + + const spec = providerAdapters.getPluginSettingsSpec(name) + + let existingValues: Record = {} + const raw = getSetting(`plugin_settings:${name}`) + if (raw) { + try { + existingValues = JSON.parse(raw) as Record + } catch { + existingValues = {} + } + } + + // Server-side required-field validation + if (spec?.fields) { + for (const field of spec.fields) { + if (!field.required) continue + const v = values[field.key] + if (field.type === 'boolean') { + if (v === undefined || v === null) { + return res.status(400).json({ + error: serverT({ en: '{{label}} is required', fr: '{{label}} est requis' }, { label: field.label }), + }) + } + } else if (field.type === 'password') { + const hasExisting = Boolean(existingValues[field.key]) + if (!hasExisting && (v === undefined || v === null || v === '')) { + return res.status(400).json({ + error: serverT({ en: '{{label}} is required', fr: '{{label}} est requis' }, { label: field.label }), + }) + } + } else { + if (v === undefined || v === null || v === '') { + return res.status(400).json({ + error: serverT({ en: '{{label}} is required', fr: '{{label}} est requis' }, { label: field.label }), + }) + } + } + } + } + + // Merge with existing stored values so empty password fields keep their previous secret + let mergedValues = values + if (spec?.fields && spec.fields.some((f) => f.type === 'password')) { + if (raw) { + try { + mergedValues = { ...existingValues } + for (const [k, v] of Object.entries(values)) { + // Only overwrite the stored secret when the client sends a non-empty value + if (spec.fields.find((f) => f.key === k)?.type === 'password' && (v === '' || v === null)) { + continue + } + mergedValues[k] = v + } + } catch { + mergedValues = values + } + } + } + + setSetting(`plugin_settings:${name}`, JSON.stringify(mergedValues)) + + if (spec?.saveSettings) { + try { + await spec.saveSettings(mergedValues) + } catch (err) { + logger.error('Failed to save plugin settings via plugin callback', { name, error: String(err) }) + return res.status(500).json({ + error: + err instanceof Error + ? err.message + : serverT({ en: 'Save settings failed', fr: 'Échec de l’enregistrement des paramètres' }), + }) + } + } + + res.json({ success: true, values: mergedValues }) + }) + router.get('/open-folder', async (_req, res) => { const pluginsDir = join(getGlobalConfigDir(config.mode ?? 'production'), 'plugins') await openFolderRoute(pluginsDir, res) @@ -243,17 +426,12 @@ export function createPluginRoutes(options: { router.get('/:name/open-folder', async (req, res) => { const name = req.params.name as string - if (!/^[a-zA-Z0-9_-]+$/.test(name)) - return res.status(400).json({ error: serverT({ en: 'Invalid plugin name', fr: 'Nom de plugin invalide' }) }) const targetDir = join(getGlobalConfigDir(config.mode ?? 'production'), 'plugins', name) await openFolderRoute(targetDir, res) }) router.delete('/:name', async (req, res) => { const name = req.params.name as string - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - return res.status(400).json({ error: serverT({ en: 'Invalid plugin name', fr: 'Nom de plugin invalide' }) }) - } const targetDir = join(getGlobalConfigDir(config.mode ?? 'production'), 'plugins', name) try { await rm(targetDir, { recursive: true, force: true }) diff --git a/web/src/components/settings/PluginSettingsModal.test.tsx b/web/src/components/settings/PluginSettingsModal.test.tsx new file mode 100644 index 000000000..be40ad4c9 --- /dev/null +++ b/web/src/components/settings/PluginSettingsModal.test.tsx @@ -0,0 +1,234 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PluginSettingsModal } from './PluginSettingsModal' +import { setLocale } from '@shared/i18n/index.js' + +const mockFetch = vi.fn() +vi.mock('../../lib/api', () => ({ + authFetch: (...args: Parameters) => mockFetch(...args), +})) + +function createJsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('PluginSettingsModal', () => { + beforeEach(() => { + setLocale('en') + mockFetch.mockReset() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('renders settings fields and saves changes', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + hasSpec: true, + spec: { + title: 'ChatGPT Plugin Settings', + description: 'Configure API settings', + fields: [ + { key: 'apiKey', label: 'API Key', type: 'password', required: true }, + { key: 'enableStream', label: 'Stream Responses', type: 'boolean', defaultValue: true }, + ], + }, + values: { enableStream: true }, + }), + ) + + const onClose = vi.fn() + render( + , + ) + + await waitFor(() => { + expect(screen.getByText('ChatGPT Plugin Settings')).toBeDefined() + }) + expect(screen.getByText('Configure API settings')).toBeDefined() + expect(screen.getByText('API Key')).toBeDefined() + + const passwordInput = screen.getByLabelText('API Key *') as HTMLInputElement + expect(passwordInput.value).toBe('') + + await userEvent.setup().type(passwordInput, 'sk-456') + + mockFetch.mockResolvedValueOnce( + createJsonResponse({ success: true, values: { apiKey: 'sk-456', enableStream: true } }), + ) + + const saveButton = screen.getByRole('button', { name: 'Save Settings' }) + await userEvent.setup().click(saveButton) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/plugins/openfox-chatgpt/settings', + expect.objectContaining({ + method: 'POST', + }), + ) + }) + }) + + it('blocks save when a required field is empty', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + hasSpec: true, + spec: { + title: 'Required Plugin Settings', + fields: [{ key: 'apiKey', label: 'API Key', type: 'password', required: true }], + }, + values: {}, + }), + ) + + render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Required Plugin Settings')).toBeDefined() + }) + + const saveButton = screen.getByRole('button', { name: 'Save Settings' }) + await userEvent.setup().click(saveButton) + + await waitFor(() => { + expect(screen.getByText('API Key is required')).toBeDefined() + }) + expect(mockFetch).not.toHaveBeenCalledWith( + '/api/plugins/openfox-req/settings', + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('renders custom UI iframe when customUiUrl is set', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + hasSpec: true, + spec: { + title: 'Custom Plugin Settings', + customUiUrl: 'http://localhost:3000/plugin-ui', + }, + values: {}, + }), + ) + + render( + , + ) + + await waitFor(() => { + expect(screen.getByTitle('Custom Custom UI')).toBeDefined() + }) + }) + + it('renders translated French labels in fr locale', async () => { + setLocale('fr') + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + hasSpec: true, + spec: { + title: 'Paramètres du plugin', + fields: [{ key: 'apiKey', label: 'Clé API', type: 'password', required: true }], + }, + values: {}, + }), + ) + + render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Paramètres du plugin')).toBeDefined() + }) + + expect(screen.getByRole('button', { name: 'Annuler' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Enregistrer les paramètres' })).toBeDefined() + + const saveButton = screen.getByRole('button', { name: 'Enregistrer les paramètres' }) + await userEvent.setup().click(saveButton) + + await waitFor(() => { + expect(screen.getByText('Clé API est requis')).toBeDefined() + }) + }) + + it('allows saving when required password was already configured', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + hasSpec: true, + spec: { + title: 'Configured Plugin Settings', + fields: [ + { key: 'apiKey', label: 'API Key', type: 'password', required: true }, + { key: 'port', label: 'Port', type: 'number', required: true }, + ], + }, + values: { port: 8080 }, + configuredKeys: ['apiKey'], + }), + ) + + render( + , + ) + + await waitFor(() => { + expect(screen.getByText('(configured)')).toBeDefined() + }) + + mockFetch.mockResolvedValueOnce(createJsonResponse({ success: true, values: { port: 9000 } })) + + const saveButton = screen.getByRole('button', { name: 'Save Settings' }) + await userEvent.setup().click(saveButton) + + await waitFor(() => { + expect(screen.getByText('Settings saved successfully!')).toBeDefined() + expect(screen.getByText('Close')).toBeDefined() + }) + }) + + it('allows saving when required boolean field is false', async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + hasSpec: true, + spec: { + title: 'Boolean Plugin Settings', + fields: [{ key: 'enabled', label: 'Enable Feature', type: 'boolean', required: true }], + }, + values: { enabled: false }, + }), + ) + + render( + , + ) + + await waitFor(() => { + expect(screen.getByText('Enable Feature')).toBeDefined() + }) + + mockFetch.mockResolvedValueOnce(createJsonResponse({ success: true, values: { enabled: false } })) + + const saveButton = screen.getByRole('button', { name: 'Save Settings' }) + await userEvent.setup().click(saveButton) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/plugins/openfox-bool/settings', + expect.objectContaining({ method: 'POST' }), + ) + }) + }) +}) diff --git a/web/src/components/settings/PluginSettingsModal.tsx b/web/src/components/settings/PluginSettingsModal.tsx new file mode 100644 index 000000000..14deb37a1 --- /dev/null +++ b/web/src/components/settings/PluginSettingsModal.tsx @@ -0,0 +1,340 @@ +import { useState, useEffect, useCallback } from 'react' +import { authFetch } from '../../lib/api' +import { useT } from '../../hooks/useT' +import { Modal } from '../shared/Modal' +import { Button } from '../shared/Button' +import type { PluginSettingField, PluginSettingsSpec } from '../../lib/pluginSettings' + +export interface PluginSettingsModalProps { + isOpen: boolean + onClose: () => void + pluginName: string + pluginDisplayName: string +} + +export function PluginSettingsModal({ isOpen, onClose, pluginName, pluginDisplayName }: PluginSettingsModalProps) { + const t = useT() + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [successMsg, setSuccessMsg] = useState(null) + const [spec, setSpec] = useState(null) + const [values, setValues] = useState>({}) + const [configuredKeys, setConfiguredKeys] = useState([]) + const [rawJson, setRawJson] = useState('{}') + + const fetchSettings = useCallback(async () => { + if (!isOpen || !pluginName) return + setLoading(true) + setError(null) + setSuccessMsg(null) + try { + const res = await authFetch(`/api/plugins/${encodeURIComponent(pluginName)}/settings`) + if (!res.ok) { + throw new Error( + t( + { + en: 'Failed to load settings (HTTP {{status}})', + fr: 'Échec du chargement des paramètres (HTTP {{status}})', + }, + { status: String(res.status) }, + ), + ) + } + const data = (await res.json()) as { + hasSpec: boolean + spec: PluginSettingsSpec | null + values: Record + configuredKeys?: string[] + } + setSpec(data.spec) + setValues(data.values ?? {}) + setConfiguredKeys(data.configuredKeys ?? []) + setRawJson(JSON.stringify(data.values ?? {}, null, 2)) + } catch (err) { + setError( + err instanceof Error + ? err.message + : t({ en: 'Error loading plugin settings', fr: 'Erreur lors du chargement des paramètres du plugin' }), + ) + } finally { + setLoading(false) + } + }, [isOpen, pluginName, t]) + + useEffect(() => { + if (isOpen) { + fetchSettings() + } + }, [isOpen, fetchSettings]) + + const handleFieldValueChange = (key: string, val: unknown) => { + setValues((prev) => ({ ...prev, [key]: val })) + } + + const validateRequired = (): string | null => { + if (!spec?.fields) return null + for (const field of spec.fields) { + if (!field.required) continue + const v = values[field.key] + if (field.type === 'password') { + const hasNew = v !== undefined && v !== null && v !== '' + const hasExisting = configuredKeys.includes(field.key) + if (!hasNew && !hasExisting) { + return t({ en: '{{label}} is required', fr: '{{label}} est requis' }, { label: field.label }) + } + } else if (field.type === 'boolean') { + if (typeof v !== 'boolean' && v !== true && v !== false) { + return t({ en: '{{label}} is required', fr: '{{label}} est requis' }, { label: field.label }) + } + } else if (v === undefined || v === null || v === '') { + return t({ en: '{{label}} is required', fr: '{{label}} est requis' }, { label: field.label }) + } + } + return null + } + + const handleSave = async () => { + setSaving(true) + setError(null) + setSuccessMsg(null) + try { + let payloadValues = values + if (!spec?.fields || spec.fields.length === 0) { + try { + payloadValues = JSON.parse(rawJson) + } catch { + throw new Error(t({ en: 'Invalid JSON settings format', fr: 'Format JSON des paramètres invalide' })) + } + } else { + const requiredError = validateRequired() + if (requiredError) throw new Error(requiredError) + } + + const res = await authFetch(`/api/plugins/${encodeURIComponent(pluginName)}/settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: payloadValues }), + }) + const data = await res.json() + if (!res.ok) { + throw new Error( + data.error ?? t({ en: 'Failed to save settings', fr: 'Échec de l’enregistrement des paramètres' }), + ) + } + setValues(payloadValues) + const newConfigured = [...configuredKeys] + if (spec?.fields) { + for (const field of spec.fields) { + if (field.type === 'password' && payloadValues[field.key]) { + if (!newConfigured.includes(field.key)) newConfigured.push(field.key) + } + } + } + setConfiguredKeys(newConfigured) + setSuccessMsg(t({ en: 'Settings saved successfully!', fr: 'Paramètres enregistrés avec succès !' })) + } catch (err) { + setError( + err instanceof Error + ? err.message + : t({ en: 'Failed to save settings', fr: 'Échec de l’enregistrement des paramètres' }), + ) + } finally { + setSaving(false) + } + } + + const renderField = (field: PluginSettingField) => { + const val = values[field.key] ?? field.defaultValue ?? '' + + switch (field.type) { + case 'boolean': + return ( + + ) + case 'select': + return ( +
+ + +
+ ) + case 'textarea': + return ( +
+ +