- Changed error message styling in ICalImportModal to use new color classes. - Updated task completion styling in TaskListView to use new success color classes. - Modified selected styling in ContactListItem tests to reflect new background class. - Adjusted duplicate warning styling in ContactImportDialog to use new warning color classes. - Refactored background and border colors in CalendarInvitationBanner for various statuses. - Updated email list item and viewer components to use new warning and success color classes. - Refined styling for unread indicators in email components. - Enhanced error fallback styling in error components to use new warning color classes. - Updated filter rule modal button hover styles to use new destructive color classes. - Added experimental feature descriptions in Plugins and Themes settings. - Refined vacation settings validation warning styling to use new warning color classes. - Updated toast component styles to use new color classes for different states. - Introduced a new built-in theme 'Qui' with specific color variables. - Adjusted email security status colors to use new warning color classes.
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import { Upload, Trash2, Power, AlertTriangle, Loader2, Package, Save, Shield } from 'lucide-react';
|
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
|
|
|
interface PluginEntry {
|
|
id: string;
|
|
name: string;
|
|
version: string;
|
|
author: string;
|
|
description: string;
|
|
type: string;
|
|
enabled: boolean;
|
|
permissions: string[];
|
|
installedAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export default function AdminPluginsPage() {
|
|
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);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
|
const [policyDirty, setPolicyDirty] = useState(false);
|
|
const [savingPolicy, setSavingPolicy] = useState(false);
|
|
|
|
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
|
|
|
async function fetchPolicy() {
|
|
try {
|
|
const res = await fetch('/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);
|
|
}
|
|
|
|
async function handleSavePolicy() {
|
|
setSavingPolicy(true);
|
|
setMessage(null);
|
|
try {
|
|
const res = await fetch('/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 fetch('/api/admin/plugins');
|
|
if (res.ok) setPlugins(await res.json());
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setUploading(true);
|
|
setMessage(null);
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/plugins', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
|
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
|
await fetchPlugins();
|
|
} else {
|
|
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
|
}
|
|
} catch {
|
|
setMessage({ type: 'error', text: 'Upload failed' });
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
|
|
async function togglePlugin(id: string, enabled: boolean) {
|
|
setMessage(null);
|
|
const res = await fetch('/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 deletePlugin(id: string, name: string) {
|
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
|
|
|
setMessage(null);
|
|
const res = await fetch('/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 (loading) {
|
|
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
|
}
|
|
|
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<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 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>
|
|
)}
|
|
|
|
{/* Plugin Policy */}
|
|
<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 items-center justify-between 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>
|
|
</div>
|
|
|
|
{/* Deployed Plugins */}
|
|
<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 items-center justify-between gap-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<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>
|
|
</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
|
|
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>
|
|
);
|
|
}
|