diff --git a/app/admin/_tabs/plugin-config-panel.tsx b/app/admin/_tabs/plugin-config-panel.tsx new file mode 100644 index 00000000..e3bea8b4 --- /dev/null +++ b/app/admin/_tabs/plugin-config-panel.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface ConfigField { + type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; + label: string; + description?: string; + required?: boolean; + default?: unknown; + placeholder?: string; + options?: { label: string; value: string }[]; +} + +interface PluginConfig { + [key: string]: unknown; +} + +interface PluginInfo { + id: string; + name: string; + description: string; + version: string; + author: string; + type: string; + permissions: string[]; + enabled: boolean; + configSchema?: Record; +} + +interface Props { + pluginId: string; + onBack: () => void; +} + +export function PluginConfigPanel({ pluginId, onBack }: Props) { + const [plugin, setPlugin] = useState(null); + const [config, setConfig] = useState({}); + const [formValues, setFormValues] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [revealSecrets, setRevealSecrets] = useState>({}); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + useEffect(() => { + let cancelled = false; + async function fetchData() { + setLoading(true); + try { + const [pluginsRes, configRes] = await Promise.all([ + apiFetch('/api/admin/plugins'), + apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`), + ]); + if (cancelled) return; + + if (pluginsRes.ok) { + const plugins: PluginInfo[] = await pluginsRes.json(); + setPlugin(plugins.find(p => p.id === pluginId) || null); + } + + if (configRes.ok) { + setConfig(await configRes.json()); + } + } finally { + if (!cancelled) setLoading(false); + } + } + fetchData(); + return () => { cancelled = true; }; + }, [pluginId]); + + useEffect(() => { + if (!plugin?.configSchema) return; + const initial: Record = {}; + for (const [key, field] of Object.entries(plugin.configSchema)) { + const stored = config[key]; + if (stored !== undefined && stored !== null) { + initial[key] = String(stored); + } else if (field.default !== undefined) { + initial[key] = String(field.default); + } else { + initial[key] = ''; + } + } + setFormValues(initial); + }, [plugin, config]); + + async function handleSaveAll() { + if (!plugin?.configSchema) return; + setSaving(true); + setMessage(null); + + for (const [key, field] of Object.entries(plugin.configSchema)) { + if (field.required && !formValues[key]?.trim()) { + setMessage({ type: 'error', text: `"${field.label}" is required` }); + setSaving(false); + return; + } + } + + try { + let hasError = false; + for (const [key, field] of Object.entries(plugin.configSchema)) { + const newVal = formValues[key] ?? ''; + const oldVal = config[key] !== undefined ? String(config[key]) : ''; + + if (newVal === oldVal) continue; + if (field.type === 'secret' && !newVal && config[key]) continue; + + let value: unknown = newVal; + if (field.type === 'boolean') value = newVal === 'true'; + else if (field.type === 'number') value = Number(newVal); + + if (!newVal && !field.required) { + const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + if (res.ok) { + setConfig(prev => { const next = { ...prev }; delete next[key]; return next; }); + } else { + hasError = true; + } + continue; + } + + const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }), + }); + if (res.ok) { + setConfig(prev => ({ ...prev, [key]: value })); + } else { + hasError = true; + } + } + + setMessage(hasError + ? { type: 'error', text: 'Some settings failed to save' } + : { type: 'success', text: 'Configuration saved' } + ); + } catch { + setMessage({ type: 'error', text: 'Failed to save configuration' }); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+ + Loading... +
+ ); + } + + if (!plugin) { + return ( +
+ +

Plugin not found: {pluginId}

+
+ ); + } + + const schema = plugin.configSchema; + const hasSchema = schema && Object.keys(schema).length > 0; + + return ( +
+
+ +
+

+ + {plugin.name} Configuration +

+

+ v{plugin.version} by {plugin.author} +

+
+
+ + {message && ( +
+ {message.text} +
+ )} + + {hasSchema ? ( +
+
+

Settings

+
+
+ {Object.entries(schema).map(([key, field]) => ( +
+ + {field.description && ( +

{field.description}

+ )} + + {field.type === 'boolean' ? ( + + ) : field.type === 'select' && field.options ? ( + + ) : field.type === 'secret' ? ( +
+ setFormValues(prev => ({ ...prev, [key]: e.target.value }))} + placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')} + className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" + /> + +
+ ) : ( + setFormValues(prev => ({ ...prev, [key]: e.target.value }))} + placeholder={field.placeholder || ''} + className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> + )} +
+ ))} + + +
+
+ ) : ( +
+

This plugin does not declare any configuration settings.

+
+ )} +
+ ); +} diff --git a/app/admin/_tabs/plugins.tsx b/app/admin/_tabs/plugins.tsx index dcd7cc3e..6569e0c9 100644 --- a/app/admin/_tabs/plugins.tsx +++ b/app/admin/_tabs/plugins.tsx @@ -1,11 +1,11 @@ 'use client'; import { useEffect, useState, useRef } from 'react'; -import Link from 'next/link'; import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react'; import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; +import { PluginConfigPanel } from './plugin-config-panel'; interface PluginEntry { id: string; @@ -30,6 +30,7 @@ export function PluginsTab() { const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); const [policyDirty, setPolicyDirty] = useState(false); const [savingPolicy, setSavingPolicy] = useState(false); + const [configuringId, setConfiguringId] = useState(null); useEffect(() => { fetchPlugins(); fetchPolicy(); }, []); @@ -250,6 +251,10 @@ export function PluginsTab() { } } + if (configuringId) { + return { setConfiguringId(null); fetchPlugins(); }} />; + } + if (loading) { return
Loading...
; } @@ -414,13 +419,14 @@ export function PluginsTab() {
- setConfiguringId(plugin.id)} title="Configure" className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors" > - + -
- ) : ( - setFormValues(prev => ({ ...prev, [key]: e.target.value }))} - placeholder={field.placeholder || ''} - className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" - /> - )} - - ))} - - - - - ) : ( -
-

This plugin does not declare any configuration settings.

-
- )} - - ); +// Inline panel handles plugin config now — see _tabs/plugin-config-panel.tsx. +// Old deep links land on the plugins tab; the user clicks the gear again. +export default function Page() { + redirect('/admin?tab=plugins'); }