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>
527 lines
22 KiB
TypeScript
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} · {plugin.type} · 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>
|
|
);
|
|
}
|