From 5b31440018d0a1653545f00f4821f8d4e6c19934 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Sun, 7 Sep 2025 11:54:22 -0500 Subject: [PATCH] Updated Plugins Added Import + Delete Added Modal confirmations Safer Defaults --- Plugins.md | 30 ++- apps/plugins/api_urls.py | 4 + apps/plugins/api_views.py | 189 +++++++++++++++++- apps/plugins/loader.py | 72 +++++-- apps/plugins/migrations/0001_initial.py | 4 +- apps/plugins/models.py | 5 +- frontend/src/api.js | 30 ++- frontend/src/pages/Plugins.jsx | 243 ++++++++++++++++++++++-- 8 files changed, 533 insertions(+), 44 deletions(-) diff --git a/Plugins.md b/Plugins.md index 1047d7f5..62ea0d87 100644 --- a/Plugins.md +++ b/Plugins.md @@ -118,13 +118,21 @@ Each action is a dict: Clicking an action calls your plugin’s `run(action, params, context)` and shows a notification with the result or error. -### Confirmation Prompt -If you want the UI to ask for confirmation before executing an action, include a boolean field with id `confirm` set to `True` (or default `True`). The UI checks it and prompts using a confirm dialog. +### Action Confirmation (Modal) +Developers can request a confirmation modal per action using the `confirm` key on the action. Options: + +- Boolean: `confirm: true` will show a default confirmation modal. +- Object: `confirm: { required: true, title: '...', message: '...' }` to customize the modal title and message. Example: ``` -fields = [ - {"id": "confirm", "label": "Require confirmation", "type": "boolean", "default": True}, +actions = [ + { + "id": "danger_run", + "label": "Do Something Risky", + "description": "Runs a job that affects many records.", + "confirm": { "required": true, "title": "Proceed?", "message": "This will modify many records." }, + } ] ``` @@ -181,6 +189,7 @@ Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking. - List plugins: `GET /api/plugins/plugins/` - Response: `{ "plugins": [{ key, name, version, description, enabled, fields, settings, actions }, ...] }` - Reload discovery: `POST /api/plugins/plugins/reload/` +- Import plugin: `POST /api/plugins/plugins/import/` with form-data file field `file` - Update settings: `POST /api/plugins/plugins//settings/` with `{"settings": {...}}` - Run action: `POST /api/plugins/plugins//run/` with `{"action": "id", "params": {...}}` - Enable/disable: `POST /api/plugins/plugins//enabled/` with `{"enabled": true|false}` @@ -190,9 +199,19 @@ Notes: --- +## Importing Plugins + +- In the UI, click the Import button on the Plugins page and upload a `.zip` containing a plugin folder. +- The archive should contain either `plugin.py` or a Python package (`__init__.py`). +- On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default). + +--- + ## Enabling / Disabling Plugins -- Each plugin has a persisted `enabled` flag in the DB (`apps/plugins/models.py`). +- Each plugin has a persisted `enabled` flag (default: disabled) and `ever_enabled` flag in the DB (`apps/plugins/models.py`). +- New plugins are disabled by default and require an explicit enable. +- The first time a plugin is enabled, the UI shows a trust warning modal explaining that plugins can run arbitrary server-side code. - The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button. - Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403. @@ -265,4 +284,3 @@ class Plugin: - Model: `apps/plugins/models.py` - Frontend page: `frontend/src/pages/Plugins.jsx` - Sidebar entry: `frontend/src/components/Sidebar.jsx` - diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py index cd2039d9..a229a07c 100644 --- a/apps/plugins/api_urls.py +++ b/apps/plugins/api_urls.py @@ -5,6 +5,8 @@ from .api_views import ( PluginSettingsAPIView, PluginRunAPIView, PluginEnabledAPIView, + PluginImportAPIView, + PluginDeleteAPIView, ) app_name = "plugins" @@ -12,6 +14,8 @@ app_name = "plugins" urlpatterns = [ path("plugins/", PluginsListAPIView.as_view(), name="list"), path("plugins/reload/", PluginReloadAPIView.as_view(), name="reload"), + path("plugins/import/", PluginImportAPIView.as_view(), name="import"), + path("plugins//delete/", PluginDeleteAPIView.as_view(), name="delete"), path("plugins//settings/", PluginSettingsAPIView.as_view(), name="settings"), path("plugins//run/", PluginRunAPIView.as_view(), name="run"), path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"), diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 1cb72078..0d68fc7d 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -3,6 +3,13 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.decorators import api_view +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile +import io +import os +import zipfile +import shutil +import tempfile from apps.accounts.permissions import ( Authenticated, permission_classes_by_method, @@ -45,6 +52,144 @@ class PluginReloadAPIView(APIView): return Response({"success": True, "count": len(pm._registry)}) +class PluginImportAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + def post(self, request): + file: UploadedFile = request.FILES.get("file") + if not file: + return Response({"success": False, "error": "Missing 'file' upload"}, status=status.HTTP_400_BAD_REQUEST) + + pm = PluginManager.get() + plugins_dir = pm.plugins_dir + + try: + zf = zipfile.ZipFile(file) + except zipfile.BadZipFile: + return Response({"success": False, "error": "Invalid zip file"}, status=status.HTTP_400_BAD_REQUEST) + + # Extract to a temporary directory first to avoid server reload thrash + tmp_root = tempfile.mkdtemp(prefix="plugin_import_") + try: + file_members = [m for m in zf.infolist() if not m.is_dir()] + if not file_members: + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST) + + for member in file_members: + name = member.filename + if not name or name.endswith("/"): + continue + # Normalize and prevent path traversal + norm = os.path.normpath(name) + if norm.startswith("..") or os.path.isabs(norm): + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Unsafe path in archive"}, status=status.HTTP_400_BAD_REQUEST) + dest_path = os.path.join(tmp_root, norm) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with zf.open(member, 'r') as src, open(dest_path, 'wb') as dst: + shutil.copyfileobj(src, dst) + + # Find candidate directory containing plugin.py or __init__.py + candidates = [] + for dirpath, dirnames, filenames in os.walk(tmp_root): + has_pluginpy = "plugin.py" in filenames + has_init = "__init__.py" in filenames + if has_pluginpy or has_init: + depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep)) + candidates.append((0 if has_pluginpy else 1, depth, dirpath)) + if not candidates: + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Invalid plugin: missing plugin.py or package __init__.py"}, status=status.HTTP_400_BAD_REQUEST) + + candidates.sort() + chosen = candidates[0][2] + # Determine plugin key: prefer chosen folder name; if chosen is tmp_root, use zip base name + base_name = os.path.splitext(getattr(file, "name", "plugin"))[0] + plugin_key = os.path.basename(chosen.rstrip(os.sep)) + if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): + plugin_key = base_name + plugin_key = plugin_key.replace(" ", "_").lower() + + final_dir = os.path.join(plugins_dir, plugin_key) + if os.path.exists(final_dir): + # If final dir exists but contains a valid plugin, refuse; otherwise clear it + if os.path.exists(os.path.join(final_dir, "plugin.py")) or os.path.exists(os.path.join(final_dir, "__init__.py")): + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": f"Plugin '{plugin_key}' already exists"}, status=status.HTTP_400_BAD_REQUEST) + try: + shutil.rmtree(final_dir) + except Exception: + pass + + # Move chosen directory into final location + if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): + # Move all contents into final_dir + os.makedirs(final_dir, exist_ok=True) + for item in os.listdir(tmp_root): + shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item)) + else: + shutil.move(chosen, final_dir) + # Cleanup temp + shutil.rmtree(tmp_root, ignore_errors=True) + target_dir = final_dir + finally: + try: + shutil.rmtree(tmp_root, ignore_errors=True) + except Exception: + pass + + # Reload discovery and validate plugin entry + pm.discover_plugins() + plugin = pm._registry.get(plugin_key) + if not plugin: + # Cleanup the copied folder to avoid leaving invalid plugin behind + try: + shutil.rmtree(target_dir, ignore_errors=True) + except Exception: + pass + return Response({"success": False, "error": "Invalid plugin: missing Plugin class in plugin.py or __init__.py"}, status=status.HTTP_400_BAD_REQUEST) + + # Extra validation: ensure Plugin.run exists + instance = getattr(plugin, "instance", None) + run_method = getattr(instance, "run", None) + if not callable(run_method): + try: + shutil.rmtree(target_dir, ignore_errors=True) + except Exception: + pass + return Response({"success": False, "error": "Invalid plugin: Plugin class must define a callable run(action, params, context)"}, status=status.HTTP_400_BAD_REQUEST) + + # Find DB config to return enabled/ever_enabled + try: + cfg = PluginConfig.objects.get(key=plugin_key) + enabled = cfg.enabled + ever_enabled = getattr(cfg, "ever_enabled", False) + except PluginConfig.DoesNotExist: + enabled = False + ever_enabled = False + + return Response({ + "success": True, + "plugin": { + "key": plugin.key, + "name": plugin.name, + "version": plugin.version, + "description": plugin.description, + "enabled": enabled, + "ever_enabled": ever_enabled, + "fields": plugin.fields or [], + "actions": plugin.actions or [], + } + }) + + class PluginSettingsAPIView(APIView): def get_permissions(self): try: @@ -115,7 +260,47 @@ class PluginEnabledAPIView(APIView): try: cfg = PluginConfig.objects.get(key=key) cfg.enabled = bool(enabled) - cfg.save(update_fields=["enabled", "updated_at"]) - return Response({"success": True, "enabled": cfg.enabled}) + # Mark that this plugin has been enabled at least once + if cfg.enabled and not cfg.ever_enabled: + cfg.ever_enabled = True + cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"]) + return Response({"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled}) except PluginConfig.DoesNotExist: return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND) + + +class PluginDeleteAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + def delete(self, request, key): + pm = PluginManager.get() + plugins_dir = pm.plugins_dir + target_dir = os.path.join(plugins_dir, key) + # Safety: ensure path inside plugins_dir + abs_plugins = os.path.abspath(plugins_dir) + os.sep + abs_target = os.path.abspath(target_dir) + if not abs_target.startswith(abs_plugins): + return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST) + + # Remove files + if os.path.isdir(target_dir): + try: + shutil.rmtree(target_dir) + except Exception as e: + return Response({"success": False, "error": f"Failed to delete plugin files: {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # Remove DB record + try: + PluginConfig.objects.filter(key=key).delete() + except Exception: + pass + + # Reload registry + pm.discover_plugins() + return Response({"success": True}) diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py index 80864688..22562917 100644 --- a/apps/plugins/loader.py +++ b/apps/plugins/loader.py @@ -71,24 +71,41 @@ class PluginManager: return self._registry def _load_plugin(self, key: str, path: str): - # Plugin can be a package or contain plugin.py - module_name = None - if os.path.exists(os.path.join(path, "__init__.py")): - module_name = key - elif os.path.exists(os.path.join(path, "plugin.py")): - module_name = f"{key}.plugin" - else: + # Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present. + has_pkg = os.path.exists(os.path.join(path, "__init__.py")) + has_pluginpy = os.path.exists(os.path.join(path, "plugin.py")) + if not (has_pkg or has_pluginpy): logger.debug(f"Skipping {path}: no plugin.py or package") return - logger.debug(f"Importing plugin module {module_name}") - module = importlib.import_module(module_name) + candidate_modules = [] + if has_pluginpy: + candidate_modules.append(f"{key}.plugin") + if has_pkg: + candidate_modules.append(key) + + module = None + plugin_cls = None + last_error = None + for module_name in candidate_modules: + try: + logger.debug(f"Importing plugin module {module_name}") + module = importlib.import_module(module_name) + plugin_cls = getattr(module, "Plugin", None) + if plugin_cls is not None: + break + else: + logger.warning(f"Module {module_name} has no Plugin class") + except Exception as e: + last_error = e + logger.exception(f"Error importing module {module_name}") - # Expect a class named Plugin in the module - plugin_cls = getattr(module, "Plugin", None) if plugin_cls is None: - logger.warning(f"Module {module_name} has no Plugin class; skipping") - return + if last_error: + raise last_error + else: + logger.warning(f"No Plugin class found for {key}; skipping") + return instance = plugin_cls() @@ -138,8 +155,10 @@ class PluginManager: def list_plugins(self) -> List[Dict[str, Any]]: from .models import PluginConfig - plugins = [] + plugins: List[Dict[str, Any]] = [] configs = {c.key: c for c in PluginConfig.objects.all()} + + # First, include all discovered plugins for key, lp in self._registry.items(): conf = configs.get(key) plugins.append( @@ -148,12 +167,35 @@ class PluginManager: "name": lp.name, "version": lp.version, "description": lp.description, - "enabled": conf.enabled if conf else True, + "enabled": conf.enabled if conf else False, + "ever_enabled": getattr(conf, "ever_enabled", False) if conf else False, "fields": lp.fields or [], "settings": (conf.settings if conf else {}), "actions": lp.actions or [], + "missing": False, } ) + + # Then, include any DB-only configs (files missing or failed to load) + discovered_keys = set(self._registry.keys()) + for key, conf in configs.items(): + if key in discovered_keys: + continue + plugins.append( + { + "key": key, + "name": conf.name, + "version": conf.version, + "description": conf.description, + "enabled": conf.enabled, + "ever_enabled": getattr(conf, "ever_enabled", False), + "fields": [], + "settings": conf.settings or {}, + "actions": [], + "missing": True, + } + ) + return plugins def get_plugin(self, key: str) -> Optional[LoadedPlugin]: diff --git a/apps/plugins/migrations/0001_initial.py b/apps/plugins/migrations/0001_initial.py index 2fc99949..dd1bdc76 100644 --- a/apps/plugins/migrations/0001_initial.py +++ b/apps/plugins/migrations/0001_initial.py @@ -15,11 +15,11 @@ class Migration(migrations.Migration): ("name", models.CharField(max_length=255)), ("version", models.CharField(blank=True, default="", max_length=64)), ("description", models.TextField(blank=True, default="")), - ("enabled", models.BooleanField(default=True)), + ("enabled", models.BooleanField(default=False)), # merged change + ("ever_enabled", models.BooleanField(default=False)), # merged addition ("settings", models.JSONField(blank=True, default=dict)), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ], ) ] - diff --git a/apps/plugins/models.py b/apps/plugins/models.py index e8e2125b..8ae0b5be 100644 --- a/apps/plugins/models.py +++ b/apps/plugins/models.py @@ -8,11 +8,12 @@ class PluginConfig(models.Model): name = models.CharField(max_length=255) version = models.CharField(max_length=64, blank=True, default="") description = models.TextField(blank=True, default="") - enabled = models.BooleanField(default=True) + enabled = models.BooleanField(default=False) + # Tracks whether this plugin has ever been enabled at least once + ever_enabled = models.BooleanField(default=False) settings = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self) -> str: return f"{self.name} ({self.key})" - diff --git a/frontend/src/api.js b/frontend/src/api.js index efdff7e3..781760cf 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1240,6 +1240,34 @@ export default class API { } } + static async importPlugin(file) { + try { + const form = new FormData(); + form.append('file', file); + const response = await request(`${host}/api/plugins/plugins/import/`, { + method: 'POST', + body: form, + }); + return response; + } catch (e) { + // Show only the concise error message for plugin import + const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin'; + notifications.show({ title: 'Import failed', message: msg, color: 'red' }); + throw e; + } + } + + static async deletePlugin(key) { + try { + const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, { + method: 'DELETE', + }); + return response; + } catch (e) { + errorNotification('Failed to delete plugin', e); + } + } + static async updatePluginSettings(key, settings) { try { const response = await request( @@ -1273,7 +1301,7 @@ export default class API { method: 'POST', body: { enabled }, }); - return response?.enabled; + return response; } catch (e) { errorNotification('Failed to update plugin enabled state', e); } diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx index d20517b8..9494e301 100644 --- a/frontend/src/pages/Plugins.jsx +++ b/frontend/src/pages/Plugins.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { AppShell, Box, + Alert, Button, Card, Group, @@ -15,8 +16,11 @@ import { Divider, ActionIcon, SimpleGrid, + Modal, + FileInput, } from '@mantine/core'; -import { RefreshCcw } from 'lucide-react'; +import { Dropzone } from '@mantine/dropzone'; +import { RefreshCcw, Trash2 } from 'lucide-react'; import API from '../api'; import { notifications } from '@mantine/notifications'; @@ -62,12 +66,23 @@ const Field = ({ field, value, onChange }) => { } }; -const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) => { +const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled, onRequireTrust, onRequestDelete }) => { const [settings, setSettings] = useState(plugin.settings || {}); const [saving, setSaving] = useState(false); const [running, setRunning] = useState(false); - const [enabled, setEnabled] = useState(plugin.enabled !== false); + const [enabled, setEnabled] = useState(!!plugin.enabled); const [lastResult, setLastResult] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmConfig, setConfirmConfig] = useState({ title: '', message: '', onConfirm: null }); + + // Keep local enabled state in sync with props (e.g., after import + enable) + React.useEffect(() => { + setEnabled(!!plugin.enabled); + }, [plugin.enabled]); + // Sync settings if plugin changes identity + React.useEffect(() => { + setSettings(plugin.settings || {}); + }, [plugin.key]); const updateField = (id, val) => { setSettings((prev) => ({ ...prev, [id]: val })); @@ -83,30 +98,52 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) => } }; + const missing = plugin.missing; return ( - +
{plugin.name} {plugin.description}
+ onRequestDelete && onRequestDelete(plugin)}> + + v{plugin.version || '1.0.0'} { const next = e.currentTarget.checked; + if (next && !plugin.ever_enabled && onRequireTrust) { + const ok = await onRequireTrust(plugin); + if (!ok) { + // Revert + setEnabled(false); + return; + } + } setEnabled(next); - await onToggleEnabled(plugin.key, next); + const resp = await onToggleEnabled(plugin.key, next); + if (next && resp?.ever_enabled) { + plugin.ever_enabled = true; + } }} size="xs" onLabel="On" offLabel="Off" + disabled={missing} />
- {plugin.fields && plugin.fields.length > 0 && ( + {missing && ( + + Missing plugin files. Re-import or delete this entry. + + )} + + {!missing && plugin.fields && plugin.fields.length > 0 && ( {plugin.fields.map((f) => ( @@ -117,7 +154,7 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) => )} - {plugin.actions && plugin.actions.length > 0 && ( + {!missing && plugin.actions && plugin.actions.length > 0 && ( <> @@ -136,18 +173,31 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) => setRunning(true); setLastResult(null); try { - // Determine if confirmation is required + // Determine if confirmation is required from action metadata or fallback field + const actionConfirm = a.confirm; const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm'); let requireConfirm = false; - if (confirmField) { + let confirmTitle = `Run ${a.label}?`; + let confirmMessage = `You're about to run "${a.label}" from "${plugin.name}".`; + if (actionConfirm) { + if (typeof actionConfirm === 'boolean') { + requireConfirm = actionConfirm; + } else if (typeof actionConfirm === 'object') { + requireConfirm = actionConfirm.required !== false; + if (actionConfirm.title) confirmTitle = actionConfirm.title; + if (actionConfirm.message) confirmMessage = actionConfirm.message; + } + } else if (confirmField) { const settingVal = settings?.confirm; const effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false; requireConfirm = !!effectiveConfirm; } if (requireConfirm) { - const ok = window.confirm(`Run "${a.label}" from "${plugin.name}"?`); - if (!ok) { return; } + await new Promise((resolve) => { + setConfirmConfig({ title: confirmTitle, message: confirmMessage, onConfirm: resolve }); + setConfirmOpen(true); + }); } // Save settings before running to ensure backend uses latest values @@ -184,6 +234,19 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) => )} + { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }} title={confirmConfig.title} centered> + + {confirmConfig.message} + + + + + +
); }; @@ -191,6 +254,17 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) => export default function PluginsPage() { const [loading, setLoading] = useState(true); const [plugins, setPlugins] = useState([]); + const [importOpen, setImportOpen] = useState(false); + const [importFile, setImportFile] = useState(null); + const [importing, setImporting] = useState(false); + const [imported, setImported] = useState(null); + const [enableAfterImport, setEnableAfterImport] = useState(false); + const [trustOpen, setTrustOpen] = useState(false); + const [trustResolve, setTrustResolve] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [uploadNoticeId, setUploadNoticeId] = useState(null); const load = async () => { setLoading(true); @@ -206,13 +280,25 @@ export default function PluginsPage() { load(); }, []); + const requireTrust = (plugin) => { + return new Promise((resolve) => { + setTrustResolve(() => resolve); + setTrustOpen(true); + }); + }; + return ( Plugins - { await API.reloadPlugins(); await load(); }} title="Reload"> - - + + + { await API.reloadPlugins(); await load(); }} title="Reload"> + + + {loading ? ( @@ -226,7 +312,17 @@ export default function PluginsPage() { plugin={p} onSaveSettings={API.updatePluginSettings} onRunAction={API.runPluginAction} - onToggleEnabled={API.setPluginEnabled} + onToggleEnabled={async (key, next) => { + const resp = await API.setPluginEnabled(key, next); + if (resp?.ever_enabled !== undefined) { + setPlugins((prev) => prev.map((pl) => pl.key === key ? { ...pl, ever_enabled: resp.ever_enabled, enabled: resp.enabled } : pl)); + } else { + setPlugins((prev) => prev.map((pl) => pl.key === key ? { ...pl, enabled: next } : pl)); + } + return resp; + }} + onRequireTrust={requireTrust} + onRequestDelete={(plugin) => { setDeleteTarget(plugin); setDeleteOpen(true); }} /> ))} @@ -237,6 +333,121 @@ export default function PluginsPage() { )} )} + {/* Import Plugin Modal */} + setImportOpen(false)} title="Import Plugin" centered> + + Upload a ZIP containing your plugin folder or package. + + Importing a plugin may briefly restart the backend (you might see a temporary disconnect). Please wait a few seconds and the app will reconnect automatically. + + files[0] && setImportFile(files[0])} onReject={() => {}} + maxFiles={1} + accept={['application/zip', 'application/x-zip-compressed', 'application/octet-stream']} + multiple={false} + > + + Drag and drop plugin .zip here + + + + + + + + {imported && ( + + + {imported.name} + {imported.description} + + Enable now + setEnableAfterImport(e.currentTarget.checked)} /> + + + + + + + )} + + + + {/* Trust Warning Modal */} + { setTrustOpen(false); trustResolve && trustResolve(false); }} title="Enable third-party plugins?" centered> + + + Plugins run server-side code with full access to your Dispatcharr instance and its data. Only enable plugins from developers you trust. + + + Why: Malicious plugins could read or modify data, call internal APIs, or perform unwanted actions. Review the source or trust the author before enabling. + + + + + + + + + {/* Delete Plugin Modal */} + { setDeleteOpen(false); setDeleteTarget(null); }} title={deleteTarget ? `Delete ${deleteTarget.name}?` : 'Delete Plugin'} centered> + + This will remove the plugin files and its configuration. This action cannot be undone. + + + + + + ); }