Updated Plugins

Added Import + Delete
Added Modal confirmations
Safer Defaults
This commit is contained in:
Dispatcharr 2025-09-07 11:54:22 -05:00
parent e9a11588c4
commit 5b31440018
8 changed files with 533 additions and 44 deletions

View file

@ -118,13 +118,21 @@ Each action is a dict:
Clicking an action calls your plugins `run(action, params, context)` and shows a notification with the result or error. Clicking an action calls your plugins `run(action, params, context)` and shows a notification with the result or error.
### Confirmation Prompt ### Action Confirmation (Modal)
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. 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: Example:
``` ```
fields = [ actions = [
{"id": "confirm", "label": "Require confirmation", "type": "boolean", "default": True}, {
"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/` - List plugins: `GET /api/plugins/plugins/`
- Response: `{ "plugins": [{ key, name, version, description, enabled, fields, settings, actions }, ...] }` - Response: `{ "plugins": [{ key, name, version, description, enabled, fields, settings, actions }, ...] }`
- Reload discovery: `POST /api/plugins/plugins/reload/` - 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/<key>/settings/` with `{"settings": {...}}` - Update settings: `POST /api/plugins/plugins/<key>/settings/` with `{"settings": {...}}`
- Run action: `POST /api/plugins/plugins/<key>/run/` with `{"action": "id", "params": {...}}` - Run action: `POST /api/plugins/plugins/<key>/run/` with `{"action": "id", "params": {...}}`
- Enable/disable: `POST /api/plugins/plugins/<key>/enabled/` with `{"enabled": true|false}` - Enable/disable: `POST /api/plugins/plugins/<key>/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 ## 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. - 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. - 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` - Model: `apps/plugins/models.py`
- Frontend page: `frontend/src/pages/Plugins.jsx` - Frontend page: `frontend/src/pages/Plugins.jsx`
- Sidebar entry: `frontend/src/components/Sidebar.jsx` - Sidebar entry: `frontend/src/components/Sidebar.jsx`

View file

@ -5,6 +5,8 @@ from .api_views import (
PluginSettingsAPIView, PluginSettingsAPIView,
PluginRunAPIView, PluginRunAPIView,
PluginEnabledAPIView, PluginEnabledAPIView,
PluginImportAPIView,
PluginDeleteAPIView,
) )
app_name = "plugins" app_name = "plugins"
@ -12,6 +14,8 @@ app_name = "plugins"
urlpatterns = [ urlpatterns = [
path("plugins/", PluginsListAPIView.as_view(), name="list"), path("plugins/", PluginsListAPIView.as_view(), name="list"),
path("plugins/reload/", PluginReloadAPIView.as_view(), name="reload"), path("plugins/reload/", PluginReloadAPIView.as_view(), name="reload"),
path("plugins/import/", PluginImportAPIView.as_view(), name="import"),
path("plugins/<str:key>/delete/", PluginDeleteAPIView.as_view(), name="delete"),
path("plugins/<str:key>/settings/", PluginSettingsAPIView.as_view(), name="settings"), path("plugins/<str:key>/settings/", PluginSettingsAPIView.as_view(), name="settings"),
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"), path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"), path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),

View file

@ -3,6 +3,13 @@ from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view 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 ( from apps.accounts.permissions import (
Authenticated, Authenticated,
permission_classes_by_method, permission_classes_by_method,
@ -45,6 +52,144 @@ class PluginReloadAPIView(APIView):
return Response({"success": True, "count": len(pm._registry)}) 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): class PluginSettingsAPIView(APIView):
def get_permissions(self): def get_permissions(self):
try: try:
@ -115,7 +260,47 @@ class PluginEnabledAPIView(APIView):
try: try:
cfg = PluginConfig.objects.get(key=key) cfg = PluginConfig.objects.get(key=key)
cfg.enabled = bool(enabled) cfg.enabled = bool(enabled)
cfg.save(update_fields=["enabled", "updated_at"]) # Mark that this plugin has been enabled at least once
return Response({"success": True, "enabled": cfg.enabled}) 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: except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND) 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})

View file

@ -71,24 +71,41 @@ class PluginManager:
return self._registry return self._registry
def _load_plugin(self, key: str, path: str): def _load_plugin(self, key: str, path: str):
# Plugin can be a package or contain plugin.py # Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present.
module_name = None has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
if os.path.exists(os.path.join(path, "__init__.py")): has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
module_name = key if not (has_pkg or has_pluginpy):
elif os.path.exists(os.path.join(path, "plugin.py")):
module_name = f"{key}.plugin"
else:
logger.debug(f"Skipping {path}: no plugin.py or package") logger.debug(f"Skipping {path}: no plugin.py or package")
return return
logger.debug(f"Importing plugin module {module_name}") candidate_modules = []
module = importlib.import_module(module_name) 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: if plugin_cls is None:
logger.warning(f"Module {module_name} has no Plugin class; skipping") if last_error:
return raise last_error
else:
logger.warning(f"No Plugin class found for {key}; skipping")
return
instance = plugin_cls() instance = plugin_cls()
@ -138,8 +155,10 @@ class PluginManager:
def list_plugins(self) -> List[Dict[str, Any]]: def list_plugins(self) -> List[Dict[str, Any]]:
from .models import PluginConfig from .models import PluginConfig
plugins = [] plugins: List[Dict[str, Any]] = []
configs = {c.key: c for c in PluginConfig.objects.all()} configs = {c.key: c for c in PluginConfig.objects.all()}
# First, include all discovered plugins
for key, lp in self._registry.items(): for key, lp in self._registry.items():
conf = configs.get(key) conf = configs.get(key)
plugins.append( plugins.append(
@ -148,12 +167,35 @@ class PluginManager:
"name": lp.name, "name": lp.name,
"version": lp.version, "version": lp.version,
"description": lp.description, "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 [], "fields": lp.fields or [],
"settings": (conf.settings if conf else {}), "settings": (conf.settings if conf else {}),
"actions": lp.actions or [], "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 return plugins
def get_plugin(self, key: str) -> Optional[LoadedPlugin]: def get_plugin(self, key: str) -> Optional[LoadedPlugin]:

View file

@ -15,11 +15,11 @@ class Migration(migrations.Migration):
("name", models.CharField(max_length=255)), ("name", models.CharField(max_length=255)),
("version", models.CharField(blank=True, default="", max_length=64)), ("version", models.CharField(blank=True, default="", max_length=64)),
("description", models.TextField(blank=True, default="")), ("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)), ("settings", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)), ("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)), ("updated_at", models.DateTimeField(auto_now=True)),
], ],
) )
] ]

View file

@ -8,11 +8,12 @@ class PluginConfig(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
version = models.CharField(max_length=64, blank=True, default="") version = models.CharField(max_length=64, blank=True, default="")
description = models.TextField(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) settings = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.name} ({self.key})" return f"{self.name} ({self.key})"

View file

@ -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) { static async updatePluginSettings(key, settings) {
try { try {
const response = await request( const response = await request(
@ -1273,7 +1301,7 @@ export default class API {
method: 'POST', method: 'POST',
body: { enabled }, body: { enabled },
}); });
return response?.enabled; return response;
} catch (e) { } catch (e) {
errorNotification('Failed to update plugin enabled state', e); errorNotification('Failed to update plugin enabled state', e);
} }

View file

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { import {
AppShell, AppShell,
Box, Box,
Alert,
Button, Button,
Card, Card,
Group, Group,
@ -15,8 +16,11 @@ import {
Divider, Divider,
ActionIcon, ActionIcon,
SimpleGrid, SimpleGrid,
Modal,
FileInput,
} from '@mantine/core'; } from '@mantine/core';
import { RefreshCcw } from 'lucide-react'; import { Dropzone } from '@mantine/dropzone';
import { RefreshCcw, Trash2 } from 'lucide-react';
import API from '../api'; import API from '../api';
import { notifications } from '@mantine/notifications'; 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 [settings, setSettings] = useState(plugin.settings || {});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [running, setRunning] = 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 [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) => { const updateField = (id, val) => {
setSettings((prev) => ({ ...prev, [id]: val })); setSettings((prev) => ({ ...prev, [id]: val }));
@ -83,30 +98,52 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
} }
}; };
const missing = plugin.missing;
return ( return (
<Card shadow="sm" radius="md" withBorder style={{ opacity: enabled ? 1 : 0.6 }}> <Card shadow="sm" radius="md" withBorder style={{ opacity: !missing && enabled ? 1 : 0.6 }}>
<Group justify="space-between" mb="xs" align="center"> <Group justify="space-between" mb="xs" align="center">
<div> <div>
<Text fw={600}>{plugin.name}</Text> <Text fw={600}>{plugin.name}</Text>
<Text size="sm" c="dimmed">{plugin.description}</Text> <Text size="sm" c="dimmed">{plugin.description}</Text>
</div> </div>
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<ActionIcon variant="subtle" color="red" title="Delete plugin" onClick={() => onRequestDelete && onRequestDelete(plugin)}>
<Trash2 size={16} />
</ActionIcon>
<Text size="xs" c="dimmed">v{plugin.version || '1.0.0'}</Text> <Text size="xs" c="dimmed">v{plugin.version || '1.0.0'}</Text>
<Switch <Switch
checked={enabled} checked={!missing && enabled}
onChange={async (e) => { onChange={async (e) => {
const next = e.currentTarget.checked; const next = e.currentTarget.checked;
if (next && !plugin.ever_enabled && onRequireTrust) {
const ok = await onRequireTrust(plugin);
if (!ok) {
// Revert
setEnabled(false);
return;
}
}
setEnabled(next); 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" size="xs"
onLabel="On" onLabel="On"
offLabel="Off" offLabel="Off"
disabled={missing}
/> />
</Group> </Group>
</Group> </Group>
{plugin.fields && plugin.fields.length > 0 && ( {missing && (
<Text size="sm" c="red">
Missing plugin files. Re-import or delete this entry.
</Text>
)}
{!missing && plugin.fields && plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm"> <Stack gap="xs" mt="sm">
{plugin.fields.map((f) => ( {plugin.fields.map((f) => (
<Field key={f.id} field={f} value={settings?.[f.id]} onChange={updateField} /> <Field key={f.id} field={f} value={settings?.[f.id]} onChange={updateField} />
@ -117,7 +154,7 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
</Stack> </Stack>
)} )}
{plugin.actions && plugin.actions.length > 0 && ( {!missing && plugin.actions && plugin.actions.length > 0 && (
<> <>
<Divider my="sm" /> <Divider my="sm" />
<Stack gap="xs"> <Stack gap="xs">
@ -136,18 +173,31 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
setRunning(true); setRunning(true);
setLastResult(null); setLastResult(null);
try { 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'); const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
let requireConfirm = false; 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 settingVal = settings?.confirm;
const effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false; const effectiveConfirm = (settingVal !== undefined ? settingVal : confirmField.default) ?? false;
requireConfirm = !!effectiveConfirm; requireConfirm = !!effectiveConfirm;
} }
if (requireConfirm) { if (requireConfirm) {
const ok = window.confirm(`Run "${a.label}" from "${plugin.name}"?`); await new Promise((resolve) => {
if (!ok) { return; } setConfirmConfig({ title: confirmTitle, message: confirmMessage, onConfirm: resolve });
setConfirmOpen(true);
});
} }
// Save settings before running to ensure backend uses latest values // Save settings before running to ensure backend uses latest values
@ -184,6 +234,19 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
</Stack> </Stack>
</> </>
)} )}
<Modal opened={confirmOpen} onClose={() => { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }} title={confirmConfig.title} centered>
<Stack>
<Text size="sm">{confirmConfig.message}</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); }}>
Cancel
</Button>
<Button size="xs" onClick={() => { const cb = confirmConfig.onConfirm; setConfirmOpen(false); setConfirmConfig({ title: '', message: '', onConfirm: null }); cb && cb(true); }}>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Card> </Card>
); );
}; };
@ -191,6 +254,17 @@ const PluginCard = ({ plugin, onSaveSettings, onRunAction, onToggleEnabled }) =>
export default function PluginsPage() { export default function PluginsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [plugins, setPlugins] = useState([]); 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 () => { const load = async () => {
setLoading(true); setLoading(true);
@ -206,13 +280,25 @@ export default function PluginsPage() {
load(); load();
}, []); }, []);
const requireTrust = (plugin) => {
return new Promise((resolve) => {
setTrustResolve(() => resolve);
setTrustOpen(true);
});
};
return ( return (
<AppShell.Main style={{ padding: 16 }}> <AppShell.Main style={{ padding: 16 }}>
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Text fw={700} size="lg">Plugins</Text> <Text fw={700} size="lg">Plugins</Text>
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload"> <Group>
<RefreshCcw size={18} /> <Button size="xs" variant="light" onClick={() => { setImportOpen(true); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>
</ActionIcon> Import Plugin
</Button>
<ActionIcon variant="light" onClick={async () => { await API.reloadPlugins(); await load(); }} title="Reload">
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group> </Group>
{loading ? ( {loading ? (
@ -226,7 +312,17 @@ export default function PluginsPage() {
plugin={p} plugin={p}
onSaveSettings={API.updatePluginSettings} onSaveSettings={API.updatePluginSettings}
onRunAction={API.runPluginAction} 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); }}
/> />
))} ))}
</SimpleGrid> </SimpleGrid>
@ -237,6 +333,121 @@ export default function PluginsPage() {
)} )}
</> </>
)} )}
{/* Import Plugin Modal */}
<Modal opened={importOpen} onClose={() => setImportOpen(false)} title="Import Plugin" centered>
<Stack>
<Text size="sm" c="dimmed">Upload a ZIP containing your plugin folder or package.</Text>
<Alert color="yellow" variant="light" title="Heads up">
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.
</Alert>
<Dropzone onDrop={(files) => files[0] && setImportFile(files[0])} onReject={() => {}}
maxFiles={1}
accept={['application/zip', 'application/x-zip-compressed', 'application/octet-stream']}
multiple={false}
>
<Group justify="center" mih={80}>
<Text size="sm">Drag and drop plugin .zip here</Text>
</Group>
</Dropzone>
<FileInput placeholder="Select plugin .zip" value={importFile} onChange={setImportFile} accept=".zip" clearable />
<Group justify="flex-end">
<Button variant="default" onClick={() => setImportOpen(false)} size="xs">Close</Button>
<Button size="xs" loading={importing} disabled={!importFile} onClick={async () => {
setImporting(true);
const id = notifications.show({ title: 'Uploading plugin', message: 'Backend may restart; please wait…', loading: true, autoClose: false, withCloseButton: false });
setUploadNoticeId(id);
try {
const resp = await API.importPlugin(importFile);
if (resp?.success && resp.plugin) {
setImported(resp.plugin);
setPlugins((prev) => [resp.plugin, ...prev.filter((p) => p.key !== resp.plugin.key)]);
notifications.update({ id, loading: false, color: 'green', title: 'Imported', message: 'Plugin imported. If the app briefly disconnected, it should be back now.', autoClose: 3000 });
} else {
notifications.update({ id, loading: false, color: 'red', title: 'Import failed', message: (resp?.error || 'Unknown error'), autoClose: 5000 });
}
} catch (e) {
// API.importPlugin already showed a concise error; just update the loading notice
notifications.update({ id, loading: false, color: 'red', title: 'Import failed', message: ((e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed'), autoClose: 5000 });
} finally {
setImporting(false);
setUploadNoticeId(null);
}
}}>Upload</Button>
</Group>
{imported && (
<Box>
<Divider my="sm" />
<Text fw={600}>{imported.name}</Text>
<Text size="sm" c="dimmed">{imported.description}</Text>
<Group justify="space-between" mt="sm" align="center">
<Text size="sm">Enable now</Text>
<Switch size="sm" checked={enableAfterImport} onChange={(e) => setEnableAfterImport(e.currentTarget.checked)} />
</Group>
<Group justify="flex-end" mt="md">
<Button variant="default" size="xs" onClick={() => { setImportOpen(false); setImported(null); setImportFile(null); setEnableAfterImport(false); }}>Done</Button>
<Button size="xs" disabled={!enableAfterImport} onClick={async () => {
if (!imported) return;
let proceed = true;
if (!imported.ever_enabled) {
proceed = await requireTrust(imported);
}
if (proceed) {
const resp = await API.setPluginEnabled(imported.key, true);
if (resp?.success) {
setPlugins((prev) => prev.map((p) => p.key === imported.key ? { ...p, enabled: true, ever_enabled: true } : p));
notifications.show({ title: imported.name, message: 'Plugin enabled', color: 'green' });
}
setImportOpen(false);
setImported(null);
setEnableAfterImport(false);
}
}}>Enable</Button>
</Group>
</Box>
)}
</Stack>
</Modal>
{/* Trust Warning Modal */}
<Modal opened={trustOpen} onClose={() => { setTrustOpen(false); trustResolve && trustResolve(false); }} title="Enable third-party plugins?" centered>
<Stack>
<Text size="sm">
Plugins run server-side code with full access to your Dispatcharr instance and its data. Only enable plugins from developers you trust.
</Text>
<Text size="sm" c="dimmed">
Why: Malicious plugins could read or modify data, call internal APIs, or perform unwanted actions. Review the source or trust the author before enabling.
</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setTrustOpen(false); trustResolve && trustResolve(false); }}>Cancel</Button>
<Button size="xs" color="red" onClick={() => { setTrustOpen(false); trustResolve && trustResolve(true); }}>I understand, enable</Button>
</Group>
</Stack>
</Modal>
{/* Delete Plugin Modal */}
<Modal opened={deleteOpen} onClose={() => { setDeleteOpen(false); setDeleteTarget(null); }} title={deleteTarget ? `Delete ${deleteTarget.name}?` : 'Delete Plugin'} centered>
<Stack>
<Text size="sm">This will remove the plugin files and its configuration. This action cannot be undone.</Text>
<Group justify="flex-end">
<Button variant="default" size="xs" onClick={() => { setDeleteOpen(false); setDeleteTarget(null); }}>Cancel</Button>
<Button size="xs" color="red" loading={deleting} onClick={async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const resp = await API.deletePlugin(deleteTarget.key);
if (resp?.success) {
setPlugins((prev) => prev.filter((p) => p.key !== deleteTarget.key));
notifications.show({ title: deleteTarget.name, message: 'Plugin deleted', color: 'green' });
}
setDeleteOpen(false);
setDeleteTarget(null);
} finally {
setDeleting(false);
}
}}>Delete</Button>
</Group>
</Stack>
</Modal>
</AppShell.Main> </AppShell.Main>
); );
} }