'use client'; import { useEffect, useState, useRef } from 'react'; 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; name: string; version: string; author: string; description: string; type: string; enabled: boolean; forceEnabled?: boolean; permissions: string[]; installedAt: string; updatedAt: string; } export function PluginsTab() { const [plugins, setPlugins] = useState([]); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // Bundle held back by the pattern scanner, awaiting an explicit admin decision. const [pendingScan, setPendingScan] = useState< { file: File; findings: Array<{ file: string; patterns: string[] }> } | null >(null); const fileInputRef = useRef(null); const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); const [policyDirty, setPolicyDirty] = useState(false); const [savingPolicy, setSavingPolicy] = useState(false); const [configuringId, setConfiguringId] = useState(null); useEffect(() => { fetchPlugins(); fetchPolicy(); }, []); async function fetchPolicy() { try { const res = await apiFetch('/api/admin/policy'); if (res.ok) { const data = await res.json(); setPolicy(data); } } catch { /* ignore */ } } function togglePluginsEnabled() { setPolicy(prev => ({ ...prev, features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled }, })); setPolicyDirty(true); setMessage(null); } function togglePluginsUploadEnabled() { setPolicy(prev => ({ ...prev, features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled }, })); setPolicyDirty(true); setMessage(null); } function toggleRequirePluginApproval() { setPolicy(prev => ({ ...prev, features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval }, })); setPolicyDirty(true); setMessage(null); } async function handleSavePolicy() { setSavingPolicy(true); setMessage(null); try { const res = await apiFetch('/api/admin/policy', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(policy), }); if (res.ok) { setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' }); setPolicyDirty(false); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Failed to save policy' }); } } catch { setMessage({ type: 'error', text: 'Failed to save policy' }); } finally { setSavingPolicy(false); } } async function fetchPlugins() { setLoading(true); try { const res = await apiFetch('/api/admin/plugins'); if (res.ok) setPlugins(await res.json()); } finally { setLoading(false); } } // Upload a bundle. The scanner may refuse it for containing patterns that are // expected in a vendored crypto library (openpgp.js, pkijs); in that case the // server returns `canOverride` and we hold the file so the admin can review // the findings and decide. `override` re-posts the same file with consent. async function uploadPlugin(file: File, override: boolean) { setUploading(true); setMessage(null); const formData = new FormData(); formData.append('file', file); if (override) formData.append('overrideWarnings', 'true'); try { const res = await apiFetch('/api/admin/plugins', { method: 'POST', body: formData, }); const data = await res.json(); if (res.ok) { setPendingScan(null); const accepted = data.findings?.length ? ` — ${data.findings.length} scanner finding(s) accepted and logged` : ''; setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${accepted}` }); await fetchPlugins(); } else if (data.canOverride && Array.isArray(data.findings) && !override) { // Hold the file rather than the error: the admin needs to see WHAT // tripped, in WHICH file, before deciding. setPendingScan({ file, findings: data.findings }); } else { setPendingScan(null); setMessage({ type: 'error', text: data.error || 'Upload failed' }); } } catch { setPendingScan(null); setMessage({ type: 'error', text: 'Upload failed' }); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ''; } } async function handleUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setPendingScan(null); await uploadPlugin(file, false); } async function togglePlugin(id: string, enabled: boolean) { setMessage(null); const res = await apiFetch('/api/admin/plugins', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, enabled }), }); if (res.ok) { setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p)); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Update failed' }); } } async function toggleForceEnabled(id: string, forceEnabled: boolean) { setMessage(null); const body: Record = { id, forceEnabled }; if (forceEnabled) body.enabled = true; const res = await apiFetch('/api/admin/plugins', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (res.ok) { setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p)); setPolicy(prev => { const current = prev.forceEnabledPlugins || []; return { ...prev, forceEnabledPlugins: forceEnabled ? [...current.filter(pid => pid !== id), id] : current.filter(pid => pid !== id), }; }); setPolicyDirty(true); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Update failed' }); } } async function forceEnableAll() { setMessage(null); const disabled = plugins.filter(p => !p.enabled); if (disabled.length === 0) { setMessage({ type: 'success', text: 'All plugins are already enabled' }); return; } let failed = 0; for (const p of disabled) { const res = await apiFetch('/api/admin/plugins', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: p.id, enabled: true }), }); if (!res.ok) failed++; } setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p)); if (failed === 0) { await fetchPlugins(); setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` }); } else { await fetchPlugins(); setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` }); } } async function forceDisableAll() { setMessage(null); const enabled = plugins.filter(p => p.enabled); if (enabled.length === 0) { setMessage({ type: 'success', text: 'All plugins are already disabled' }); return; } let failed = 0; for (const p of enabled) { const res = await apiFetch('/api/admin/plugins', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: p.id, enabled: false }), }); if (!res.ok) failed++; } if (failed === 0) { await fetchPlugins(); setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` }); } else { await fetchPlugins(); setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` }); } } async function deletePlugin(id: string, name: string) { if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return; setMessage(null); const res = await apiFetch('/api/admin/plugins', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }); if (res.ok) { setPlugins(prev => prev.filter(p => p.id !== id)); setMessage({ type: 'success', text: `Plugin "${name}" removed` }); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Delete failed' }); } } if (configuringId) { return { setConfiguringId(null); fetchPlugins(); }} />; } if (loading) { return
Loading...
; } const pluginsEnabled = policy.features.pluginsEnabled ?? true; const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true; const requirePluginApproval = policy.features.requirePluginApproval ?? true; return (

Plugins

Manage plugins and plugin policy for all users

{policyDirty && ( )}
{message && (
{message.text}
)} {pendingScan && (

Scanner flagged {pendingScan.file.name}

These patterns can indicate malicious code, but they also appear in legitimate minified crypto libraries such as openpgp.js and pkijs. Review the findings before proceeding — installing anyway is recorded in the audit log.

    {pendingScan.findings.map(f => (
  • {f.file} — {f.patterns.join(', ')}
  • ))}
)}

Plugin Policy

Control plugin availability for users

Plugins Enabled

Allow the plugin system to load and run plugins for users

User Plugin Uploads

Allow users to upload plugin ZIP files in Settings

Require Admin Approval

User-uploaded plugins must be approved by an admin before they can be enabled

{plugins.length > 0 && (
Force Enable / Disable All

Bulk toggle all deployed plugins at once

)}

Deployed Plugins

Admin-uploaded plugins for all users

{plugins.length === 0 ? (

No plugins installed

Upload a plugin ZIP file to get started

) : (
{plugins.map(plugin => (
{plugin.name} v{plugin.version} {plugin.enabled ? 'Enabled' : 'Disabled'} {plugin.forceEnabled && ( Forced )}
{plugin.description && (

{plugin.description}

)}
by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
{plugin.permissions.length > 0 && (
Permissions: {plugin.permissions.join(', ')}
)}
))}
)}
); }