Files
SRCmail/app/(main)/admin/_tabs/plugins.tsx
T
Bernd RodlerandClaude Opus 4.8 e9746fcf78 feat(plugins): admin review panel for scanner findings
The overrideWarnings escape hatch added alongside the bundle scan was
API-only: an admin uploading a crypto plugin through the web form hit a
400 with canOverride and had no way to act on it, which left S/MIME and
PGP bundles uninstallable through the UI.

Hold the rejected file client-side and show the findings — pattern per
file — with "Install anyway" and "Cancel". Proceeding re-posts the same
file with overrideWarnings, so the decision stays explicit and lands in
the audit log. The route now echoes accepted findings back on success so
the confirmation says how many were waved through rather than reporting a
bare install.

Also replaces a dead `data.warnings` read with the live `findings` field;
the route never returned `warnings` on success, so that branch never ran.

Completes B-01.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:03:14 +02:00

527 lines
22 KiB
TypeScript

'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<PluginEntry[]>([]);
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<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
const [configuringId, setConfiguringId] = useState<string | null>(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<HTMLInputElement>) {
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<string, unknown> = { 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 <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Plugin
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{pendingScan && (
<div className="border border-warning/40 bg-warning/5 rounded-lg p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-warning mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Scanner flagged <span className="font-mono">{pendingScan.file.name}</span>
</p>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
</div>
<ul className="space-y-1">
{pendingScan.findings.map(f => (
<li key={f.file} className="text-xs font-mono bg-background/60 border border-border rounded px-2 py-1">
<span className="text-foreground">{f.file}</span>
<span className="text-muted-foreground"> {f.patterns.join(', ')}</span>
</li>
))}
</ul>
<div className="flex items-center gap-2">
<button
onClick={() => uploadPlugin(pendingScan.file, true)}
disabled={uploading}
className="inline-flex items-center gap-2 h-8 px-3 rounded-md bg-destructive text-destructive-foreground text-xs font-medium hover:bg-destructive/90 disabled:opacity-50 transition-all"
>
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <AlertTriangle className="w-3.5 h-3.5" />}
Install anyway
</button>
<button
onClick={() => { setPendingScan(null); setMessage(null); }}
disabled={uploading}
className="inline-flex items-center h-8 px-3 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50 transition-all"
>
Cancel
</button>
</div>
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
</div>
<button onClick={togglePluginsEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
</div>
<button onClick={togglePluginsUploadEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
</div>
<button onClick={toggleRequirePluginApproval}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{plugins.length > 0 && (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
>
<Power className="w-3.5 h-3.5" />
Enable All
</button>
<button
onClick={forceDisableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
>
<PowerOff className="w-3.5 h-3.5" />
Disable All
</button>
</div>
</div>
)}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
</div>
{plugins.length === 0 ? (
<div className="p-12 text-center">
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{plugin.enabled ? 'Enabled' : 'Disabled'}
</span>
{plugin.forceEnabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
<Lock className="w-3 h-3" /> Forced
</span>
)}
</div>
{plugin.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1">
by {plugin.author} &middot; {plugin.type} &middot; installed {new Date(plugin.installedAt).toLocaleDateString()}
</div>
{plugin.permissions.length > 0 && (
<div className="flex items-center gap-1 mt-1">
<AlertTriangle className="w-3 h-3 text-warning" />
<span className="text-xs text-warning">
Permissions: {plugin.permissions.join(', ')}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setConfiguringId(plugin.id)}
title="Configure"
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
>
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
</button>
<button
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
title={plugin.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deletePlugin(plugin.id, plugin.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}