fc96a2f86c
Backend: - PluginManifest um 5 neue UI-Felder erweitert: menu_items, page_routes, detail_tabs, settings_pages, dashboard_widgets (FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage, FrontendDashboardWidget) - GET /api/v1/plugins/active-manifests Endpoint liefert UI-Manifeste aller aktiven Plugins - Registry.get_active_manifests() + PluginService.get_active_manifests() - 12 Built-in Plugins mit UI-Manifest-Daten gefuellt (menu_items, page_routes, detail_tabs, settings_pages) - Plugin-Install-System: POST /upload (ZIP), POST /install-url (URL) mit Validierung (Manifest, dangerous imports, SQL migrations) Frontend: - pluginStore.ts (Zustand) mit PluginUiManifest Typen + Selektoren - useActivePluginManifests() React Query Hook - PluginRegistry.tsx — fetcht Manifeste beim App-Start - PluginLoader.tsx — dynamisches React.lazy() mit ErrorBoundary - PluginRouteRenderer.tsx — Catch-all fuer Plugin-Routes - routes/index.tsx — Catch-all Routes fuer Plugin-Pages + Settings - Sidebar.tsx — dynamische Plugin Menu-Items mit Grouping + Icons - Settings.tsx — dynamische Plugin Settings-Pages - ContactDetail.tsx — dynamische Plugin Detail-Tabs mit Permissions - AppShell.tsx — PluginRegistry Provider eingebunden - SettingsPlugins.tsx — Install-UI (ZIP Upload + URL Install) - plugins.ts — useUploadPlugin() + useInstallPluginFromUrl() Hooks Docs & Templates: - docs/plugin-development-guide.md — komplette Entwickler-Doku - templates/plugin-template/ — Boilerplate mit allen Manifest-Feldern Tests: - 34 Vitest-Tests (PluginRegistry, PluginLoader, PluginRouteRenderer, pluginStore) — alle bestanden - TSC: keine neuen Errors (nur pre-existing Dms.tsx)
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
usePlugins,
|
|
useInstallPlugin,
|
|
useActivatePlugin,
|
|
useDeactivatePlugin,
|
|
useUninstallPlugin,
|
|
useUploadPlugin,
|
|
useInstallPluginFromUrl,
|
|
} from '@/api/hooks';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
|
import { useToast } from '@/components/ui/Toast';
|
|
|
|
interface Plugin {
|
|
name: string;
|
|
display_name?: string;
|
|
description?: string;
|
|
version?: string;
|
|
status: 'discovered' | 'installed' | 'active' | 'inactive';
|
|
installed?: boolean;
|
|
active?: boolean;
|
|
}
|
|
|
|
function statusBadgeVariant(status: string): 'secondary' | 'primary' | 'success' | 'warning' {
|
|
switch (status) {
|
|
case 'active':
|
|
return 'success';
|
|
case 'installed':
|
|
case 'inactive':
|
|
return 'warning';
|
|
default:
|
|
return 'secondary';
|
|
}
|
|
}
|
|
|
|
function statusLabel(status: string, t: (k: string) => string): string {
|
|
switch (status) {
|
|
case 'active':
|
|
return t('settings.pluginActive');
|
|
case 'installed':
|
|
return t('settings.pluginInstalled');
|
|
case 'inactive':
|
|
return t('settings.pluginInactive');
|
|
default:
|
|
return t('settings.pluginDiscovered');
|
|
}
|
|
}
|
|
|
|
function InstallPluginSection() {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const uploadMutation = useUploadPlugin();
|
|
const installUrlMutation = useInstallPluginFromUrl();
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [url, setUrl] = useState('');
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0] || null;
|
|
setSelectedFile(file);
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (!selectedFile) {
|
|
toast.error(t('settings.pluginSelectFile'));
|
|
return;
|
|
}
|
|
try {
|
|
await uploadMutation.mutateAsync(selectedFile);
|
|
toast.success(t('settings.pluginUploadedSuccess'));
|
|
setSelectedFile(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
} catch (err: any) {
|
|
toast.error(err.message || t('common.error'));
|
|
}
|
|
};
|
|
|
|
const handleInstallUrl = async () => {
|
|
if (!url.trim()) {
|
|
toast.error(t('settings.pluginEnterUrl'));
|
|
return;
|
|
}
|
|
try {
|
|
await installUrlMutation.mutateAsync(url.trim());
|
|
toast.success(t('settings.pluginUrlInstalledSuccess'));
|
|
setUrl('');
|
|
} catch (err: any) {
|
|
toast.error(err.message || t('common.error'));
|
|
}
|
|
};
|
|
|
|
const isBusy = uploadMutation.isPending || installUrlMutation.isPending;
|
|
|
|
return (
|
|
<Card className="mb-6">
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold text-secondary-900 mb-4">
|
|
{t('settings.installPlugin')}
|
|
</h2>
|
|
|
|
{/* ZIP Upload */}
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
|
{t('settings.uploadZip')}
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".zip"
|
|
onChange={handleFileSelect}
|
|
className="block w-full text-sm text-secondary-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
|
data-testid="plugin-zip-upload-input"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleUpload}
|
|
isLoading={uploadMutation.isPending}
|
|
disabled={!selectedFile || isBusy}
|
|
data-testid="plugin-upload-btn"
|
|
>
|
|
{t('settings.upload')}
|
|
</Button>
|
|
</div>
|
|
{selectedFile && (
|
|
<p className="text-xs text-secondary-400 mt-1">
|
|
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* URL Install */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 mb-2">
|
|
{t('settings.installFromUrl')}
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="https://example.com/plugin.zip"
|
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
data-testid="plugin-url-input"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleInstallUrl}
|
|
isLoading={installUrlMutation.isPending}
|
|
disabled={!url.trim() || isBusy}
|
|
data-testid="plugin-install-url-btn"
|
|
>
|
|
{t('settings.install')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function SettingsPluginsPage() {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const { data, isLoading, isError, error, refetch } = usePlugins();
|
|
const installMutation = useInstallPlugin();
|
|
const activateMutation = useActivatePlugin();
|
|
const deactivateMutation = useDeactivatePlugin();
|
|
const uninstallMutation = useUninstallPlugin();
|
|
|
|
const [confirmUninstall, setConfirmUninstall] = useState<Plugin | null>(null);
|
|
const [confirmRemoveData, setConfirmRemoveData] = useState(false);
|
|
|
|
const plugins: Plugin[] = data?.plugins ?? data ?? [];
|
|
|
|
const handleInstall = async (plugin: Plugin) => {
|
|
try {
|
|
await installMutation.mutateAsync(plugin.name);
|
|
toast.success(t('settings.pluginInstalledSuccess'));
|
|
} catch (err: any) {
|
|
toast.error(err.message || t('common.error'));
|
|
}
|
|
};
|
|
|
|
const handleActivate = async (plugin: Plugin) => {
|
|
try {
|
|
await activateMutation.mutateAsync(plugin.name);
|
|
toast.success(t('settings.pluginActivatedSuccess'));
|
|
} catch (err: any) {
|
|
toast.error(err.message || t('common.error'));
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async (plugin: Plugin) => {
|
|
try {
|
|
await deactivateMutation.mutateAsync(plugin.name);
|
|
toast.success(t('settings.pluginDeactivatedSuccess'));
|
|
} catch (err: any) {
|
|
toast.error(err.message || t('common.error'));
|
|
}
|
|
};
|
|
|
|
const handleUninstall = async () => {
|
|
if (!confirmUninstall) return;
|
|
try {
|
|
await uninstallMutation.mutateAsync({ name: confirmUninstall.name, removeData: confirmRemoveData });
|
|
toast.success(t('settings.pluginUninstalledSuccess'));
|
|
setConfirmUninstall(null);
|
|
setConfirmRemoveData(false);
|
|
} catch (err: any) {
|
|
toast.error(err.message || t('common.error'));
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-plugins-page">
|
|
<Skeleton className="h-8 w-48 mb-6" />
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-20" />
|
|
<Skeleton className="h-20" />
|
|
<Skeleton className="h-20" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-plugins-page">
|
|
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('settings.plugins')}</h1>
|
|
<EmptyState
|
|
title={t('common.error')}
|
|
action={<Button onClick={() => refetch()}>{t('common.reset')}</Button>}
|
|
>
|
|
<p className="text-sm text-secondary-500">{error?.message || t('common.error')}</p>
|
|
</EmptyState>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-plugins-page">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.plugins')}</h1>
|
|
<Button variant="secondary" size="sm" onClick={() => refetch()} data-testid="refresh-plugins-btn">
|
|
{t('common.reset')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Install Plugin Section */}
|
|
<InstallPluginSection />
|
|
|
|
{plugins.length === 0 ? (
|
|
<EmptyState title={t('settings.noPlugins')} />
|
|
) : (
|
|
<div className="space-y-4">
|
|
{plugins.map((plugin) => {
|
|
const status = plugin.active ? 'active' : plugin.installed ? 'inactive' : plugin.status || 'discovered';
|
|
const isBusy =
|
|
installMutation.isPending ||
|
|
activateMutation.isPending ||
|
|
deactivateMutation.isPending ||
|
|
uninstallMutation.isPending;
|
|
|
|
return (
|
|
<Card key={plugin.name}>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<h3 className="text-lg font-semibold text-secondary-900">
|
|
{plugin.display_name || plugin.name}
|
|
</h3>
|
|
<Badge variant={statusBadgeVariant(status)}>
|
|
{statusLabel(status, t)}
|
|
</Badge>
|
|
{plugin.version && (
|
|
<span className="text-xs text-secondary-400">v{plugin.version}</span>
|
|
)}
|
|
</div>
|
|
{plugin.description && (
|
|
<p className="text-sm text-secondary-600">{plugin.description}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{status === 'discovered' && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleInstall(plugin)}
|
|
isLoading={installMutation.isPending}
|
|
disabled={isBusy}
|
|
data-testid={`install-plugin-${plugin.name}`}
|
|
>
|
|
{t('settings.pluginInstall')}
|
|
</Button>
|
|
)}
|
|
{status === 'inactive' && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleActivate(plugin)}
|
|
isLoading={activateMutation.isPending}
|
|
disabled={isBusy}
|
|
data-testid={`activate-plugin-${plugin.name}`}
|
|
>
|
|
{t('settings.pluginActivate')}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={() => setConfirmUninstall(plugin)}
|
|
disabled={isBusy}
|
|
data-testid={`uninstall-plugin-${plugin.name}`}
|
|
>
|
|
{t('settings.pluginUninstall')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{status === 'active' && (
|
|
<>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => handleDeactivate(plugin)}
|
|
isLoading={deactivateMutation.isPending}
|
|
disabled={isBusy}
|
|
data-testid={`deactivate-plugin-${plugin.name}`}
|
|
>
|
|
{t('settings.pluginDeactivate')}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={() => setConfirmUninstall(plugin)}
|
|
disabled={isBusy}
|
|
data-testid={`uninstall-plugin-${plugin.name}`}
|
|
>
|
|
{t('settings.pluginUninstall')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={!!confirmUninstall}
|
|
title={t('settings.pluginUninstall')}
|
|
message={`${t('settings.pluginUninstallConfirm')}: ${confirmUninstall?.display_name || confirmUninstall?.name}?`}
|
|
variant="danger"
|
|
onConfirm={handleUninstall}
|
|
onCancel={() => {
|
|
setConfirmUninstall(null);
|
|
setConfirmRemoveData(false);
|
|
}}
|
|
>
|
|
<label className="flex items-center gap-2 mt-3 cursor-pointer min-h-touch">
|
|
<input
|
|
type="checkbox"
|
|
checked={confirmRemoveData}
|
|
onChange={(e) => setConfirmRemoveData(e.target.checked)}
|
|
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
<span className="text-sm text-secondary-700">{t('settings.pluginRemoveData')}</span>
|
|
</label>
|
|
</ConfirmDialog>
|
|
</div>
|
|
);
|
|
} |