From fc96a2f86ca6932cb9f4eb3c9f64332642ca5227 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 23 Jul 2026 19:01:18 +0200 Subject: [PATCH] Phase 3: Plugin-UI-System (WordPress-Style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/plugins/builtins/ai_assistant/plugin.py | 11 +- app/plugins/builtins/ai_proactive/plugin.py | 5 +- app/plugins/builtins/calendar/plugin.py | 13 +- app/plugins/builtins/dms/plugin.py | 13 +- app/plugins/builtins/entity_links/plugin.py | 5 +- app/plugins/builtins/kommunikation/plugin.py | 8 +- app/plugins/builtins/mail/plugin.py | 16 +- app/plugins/builtins/permissions/plugin.py | 7 +- .../builtins/report_generator/plugin.py | 8 +- app/plugins/builtins/system_notif/plugin.py | 5 +- app/plugins/builtins/tags/plugin.py | 5 +- app/plugins/builtins/unified_search/plugin.py | 5 +- app/plugins/manifest.py | 125 +++ app/plugins/registry.py | 31 + app/routes/plugins.py | 452 ++++++++- app/services/plugin_service.py | 4 + docs/plugin-development-guide.md | 869 ++++++++++++++---- frontend/src/api/pluginManifests.ts | 15 + frontend/src/api/plugins.ts | 34 +- .../src/components/contacts/ContactDetail.tsx | 63 +- frontend/src/components/layout/AppShell.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 106 +++ .../src/components/plugins/PluginLoader.tsx | 84 ++ .../src/components/plugins/PluginRegistry.tsx | 35 + .../plugins/PluginRouteRenderer.tsx | 62 ++ .../plugins/__tests__/PluginLoader.test.tsx | 63 ++ .../plugins/__tests__/PluginRegistry.test.tsx | 78 ++ .../__tests__/PluginRouteRenderer.test.tsx | 120 +++ frontend/src/pages/Settings.tsx | 19 +- frontend/src/pages/SettingsPlugins.tsx | 133 ++- frontend/src/routes/index.tsx | 3 + .../src/store/__tests__/pluginStore.test.ts | 187 ++++ frontend/src/store/pluginStore.ts | 128 +++ templates/plugin-template/README.md | 60 ++ templates/plugin-template/__init__.py | 1 + .../migrations/0001_initial.sql | 17 + templates/plugin-template/models.py | 38 + templates/plugin-template/plugin.py | 178 ++++ templates/plugin-template/routes.py | 48 + templates/plugin-template/schemas.py | 50 + templates/plugin-template/services.py | 39 + templates/plugin-template/tests/__init__.py | 1 + .../plugin-template/tests/test_plugin.py | 63 ++ 43 files changed, 3005 insertions(+), 204 deletions(-) create mode 100644 frontend/src/api/pluginManifests.ts create mode 100644 frontend/src/components/plugins/PluginLoader.tsx create mode 100644 frontend/src/components/plugins/PluginRegistry.tsx create mode 100644 frontend/src/components/plugins/PluginRouteRenderer.tsx create mode 100644 frontend/src/components/plugins/__tests__/PluginLoader.test.tsx create mode 100644 frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx create mode 100644 frontend/src/components/plugins/__tests__/PluginRouteRenderer.test.tsx create mode 100644 frontend/src/store/__tests__/pluginStore.test.ts create mode 100644 frontend/src/store/pluginStore.ts create mode 100644 templates/plugin-template/README.md create mode 100644 templates/plugin-template/__init__.py create mode 100644 templates/plugin-template/migrations/0001_initial.sql create mode 100644 templates/plugin-template/models.py create mode 100644 templates/plugin-template/plugin.py create mode 100644 templates/plugin-template/routes.py create mode 100644 templates/plugin-template/schemas.py create mode 100644 templates/plugin-template/services.py create mode 100644 templates/plugin-template/tests/__init__.py create mode 100644 templates/plugin-template/tests/test_plugin.py diff --git a/app/plugins/builtins/ai_assistant/plugin.py b/app/plugins/builtins/ai_assistant/plugin.py index c73e89f..dd17d7d 100644 --- a/app/plugins/builtins/ai_assistant/plugin.py +++ b/app/plugins/builtins/ai_assistant/plugin.py @@ -6,7 +6,7 @@ import logging from typing import Any from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute, FrontendSettingsPage logger = logging.getLogger(__name__) @@ -40,6 +40,15 @@ class AIAssistantPlugin(BasePlugin): "ai:tools", ], is_core=True, + menu_items=[ + FrontendMenuItem(label_key='nav.aiAssistant', label='AI Assistant', path='/ai-assistant', icon='Bot', order=90), + ], + page_routes=[ + FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True), + ], + settings_pages=[ + FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60), + ], ) def __init__(self) -> None: diff --git a/app/plugins/builtins/ai_proactive/plugin.py b/app/plugins/builtins/ai_proactive/plugin.py index 2c7d783..e99a8e1 100644 --- a/app/plugins/builtins/ai_proactive/plugin.py +++ b/app/plugins/builtins/ai_proactive/plugin.py @@ -11,7 +11,7 @@ import logging from typing import Any from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendSettingsPage logger = logging.getLogger(__name__) @@ -43,6 +43,9 @@ class AIProactivePlugin(BasePlugin): "ai_proactive:config", ], is_core=False, + settings_pages=[ + FrontendSettingsPage(path='ai-proactive', label_key='settings.aiProactive', label='Proactive AI', component='@/pages/ProactiveAISettings', icon='Sparkles', order=61), + ], ) def __init__(self) -> None: diff --git a/app/plugins/builtins/calendar/plugin.py b/app/plugins/builtins/calendar/plugin.py index a5020a3..4d12047 100644 --- a/app/plugins/builtins/calendar/plugin.py +++ b/app/plugins/builtins/calendar/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute, FrontendDetailTab class CalendarPlugin(BasePlugin): @@ -41,4 +41,15 @@ class CalendarPlugin(BasePlugin): "calendar:share", "calendar:admin", ], + menu_items=[ + FrontendMenuItem(label_key='nav.calendar', label='Calendar', path='/calendar', icon='Calendar', order=20), + FrontendMenuItem(label_key='nav.calendarKanban', label='Calendar Kanban', path='/calendar/kanban', icon='KanbanSquare', group='nav.calendar', order=21), + ], + page_routes=[ + FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True), + FrontendPageRoute(path='/calendar/kanban', component='@/pages/CalendarKanban', protected=True), + ], + detail_tabs=[ + FrontendDetailTab(entity_type='contact', label_key='tabs.calendar', label='Calendar', component='@/components/contact/ContactCalendarTab', icon='Calendar', order=30, permission='calendar:read'), + ], ) diff --git a/app/plugins/builtins/dms/plugin.py b/app/plugins/builtins/dms/plugin.py index fb38968..ada375d 100644 --- a/app/plugins/builtins/dms/plugin.py +++ b/app/plugins/builtins/dms/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute, FrontendDetailTab class DmsPlugin(BasePlugin): @@ -31,4 +31,15 @@ class DmsPlugin(BasePlugin): "dms:share", "dms:admin", ], + menu_items=[ + FrontendMenuItem(label_key='nav.files', label='Files', path='/dms', icon='FolderOpen', order=40), + FrontendMenuItem(label_key='nav.trash', label='Trash', path='/dms/trash', icon='Trash2', group='nav.files', order=41), + ], + page_routes=[ + FrontendPageRoute(path='/dms', component='@/pages/Dms', protected=True), + FrontendPageRoute(path='/dms/trash', component='@/pages/DmsTrash', protected=True), + ], + detail_tabs=[ + FrontendDetailTab(entity_type='contact', label_key='tabs.files', label='Dateien', component='@/components/contact/ContactFilesTab', icon='FolderOpen', order=40, permission='dms:read'), + ], ) diff --git a/app/plugins/builtins/entity_links/plugin.py b/app/plugins/builtins/entity_links/plugin.py index 10d18c4..e3c329a 100644 --- a/app/plugins/builtins/entity_links/plugin.py +++ b/app/plugins/builtins/entity_links/plugin.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendDetailTab class EntityLinksPlugin(BasePlugin): @@ -37,6 +37,9 @@ class EntityLinksPlugin(BasePlugin): "entity_links:delete", ], is_core=True, + detail_tabs=[ + FrontendDetailTab(entity_type='contact', label_key='tabs.links', label='Verknüpfungen', component='@/components/contact/ContactLinksTab', icon='Link', order=60, permission='entity_links:read'), + ], ) async def on_contact_deleted(self, payload: dict[str, Any]) -> None: diff --git a/app/plugins/builtins/kommunikation/plugin.py b/app/plugins/builtins/kommunikation/plugin.py index f48d920..c4ed863 100644 --- a/app/plugins/builtins/kommunikation/plugin.py +++ b/app/plugins/builtins/kommunikation/plugin.py @@ -6,7 +6,7 @@ import logging from typing import Any from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute logger = logging.getLogger(__name__) @@ -49,6 +49,12 @@ class KommunikationPlugin(BasePlugin): "comm:delete", ], is_core=True, + menu_items=[ + FrontendMenuItem(label_key='nav.communication', label='Communication', path='/communication', icon='MessageSquare', order=80), + ], + page_routes=[ + FrontendPageRoute(path='/communication', component='@/pages/Communication', protected=True), + ], ) async def on_activate(self, db, service_container, event_bus) -> None: diff --git a/app/plugins/builtins/mail/plugin.py b/app/plugins/builtins/mail/plugin.py index 194faac..16b7eb8 100644 --- a/app/plugins/builtins/mail/plugin.py +++ b/app/plugins/builtins/mail/plugin.py @@ -7,7 +7,7 @@ import logging from typing import Any from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage logger = logging.getLogger(__name__) @@ -56,6 +56,20 @@ class MailPlugin(BasePlugin): events=[], migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql"], permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"], + menu_items=[ + FrontendMenuItem(label_key='nav.email', label='Mail', path='/mail', icon='Mail', order=30), + FrontendMenuItem(label_key='nav.emailSettings', label='Mail Settings', path='/mail/settings', icon='Settings', group='nav.email', order=31), + ], + page_routes=[ + FrontendPageRoute(path='/mail', component='@/pages/Mail', protected=True), + FrontendPageRoute(path='/mail/settings', component='@/pages/MailSettings', protected=True), + ], + settings_pages=[ + FrontendSettingsPage(path='mail', label_key='settings.mail', label='Mail', component='@/pages/MailSettings', icon='Mail', order=50), + ], + detail_tabs=[ + FrontendDetailTab(entity_type='contact', label_key='tabs.email', label='E-Mails', component='@/components/contact/ContactMailTab', icon='Mail', order=20, permission='mail:read'), + ], ) async def on_activate( diff --git a/app/plugins/builtins/permissions/plugin.py b/app/plugins/builtins/permissions/plugin.py index fad113b..a3fb1fb 100644 --- a/app/plugins/builtins/permissions/plugin.py +++ b/app/plugins/builtins/permissions/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendSettingsPage class PermissionsPlugin(BasePlugin): @@ -31,4 +31,9 @@ class PermissionsPlugin(BasePlugin): migrations=["0001_initial.sql"], permissions=[], is_core=True, + settings_pages=[ + FrontendSettingsPage(path='roles', label_key='settings.roles', label='Roles', component='@/pages/SettingsRoles', icon='Shield', order=10, permission='permissions:read'), + FrontendSettingsPage(path='users', label_key='settings.users', label='Users', component='@/pages/SettingsUsers', icon='Users', order=11, permission='permissions:read'), + FrontendSettingsPage(path='groups', label_key='settings.groups', label='Groups', component='@/pages/SettingsGroups', icon='UsersRound', order=12, permission='permissions:read'), + ], ) diff --git a/app/plugins/builtins/report_generator/plugin.py b/app/plugins/builtins/report_generator/plugin.py index cb62903..1cc22c2 100644 --- a/app/plugins/builtins/report_generator/plugin.py +++ b/app/plugins/builtins/report_generator/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendMenuItem, FrontendPageRoute class ReportGeneratorPlugin(BasePlugin): @@ -26,4 +26,10 @@ class ReportGeneratorPlugin(BasePlugin): events=["report.requested", "report.generated"], migrations=["0001_initial.sql"], permissions=["reports.read", "reports.generate", "reports.manage_templates"], + menu_items=[ + FrontendMenuItem(label_key='nav.reports', label='Reports', path='/reports', icon='BarChart3', order=70), + ], + page_routes=[ + FrontendPageRoute(path='/reports', component='@/pages/Reports', protected=True), + ], ) diff --git a/app/plugins/builtins/system_notif/plugin.py b/app/plugins/builtins/system_notif/plugin.py index e81cd20..153ff19 100644 --- a/app/plugins/builtins/system_notif/plugin.py +++ b/app/plugins/builtins/system_notif/plugin.py @@ -6,7 +6,7 @@ import logging from typing import Any from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest +from app.plugins.manifest import PluginManifest, FrontendSettingsPage logger = logging.getLogger(__name__) @@ -38,6 +38,9 @@ class SystemNotifPlugin(BasePlugin): migrations=[], permissions=["system_notif:read"], is_core=False, + settings_pages=[ + FrontendSettingsPage(path='notifications', label_key='settings.notifications', label='Notifications', component='@/pages/SettingsNotifications', icon='Bell', order=40), + ], ) def __init__(self) -> None: diff --git a/app/plugins/builtins/tags/plugin.py b/app/plugins/builtins/tags/plugin.py index 0dec765..8aeab05 100644 --- a/app/plugins/builtins/tags/plugin.py +++ b/app/plugins/builtins/tags/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendDetailTab class TagsPlugin(BasePlugin): @@ -31,4 +31,7 @@ class TagsPlugin(BasePlugin): "tags:admin", ], is_core=True, + detail_tabs=[ + FrontendDetailTab(entity_type='contact', label_key='tabs.tags', label='Tags', component='@/components/contact/ContactTagsTab', icon='Tag', order=50, permission='tags:read'), + ], ) diff --git a/app/plugins/builtins/unified_search/plugin.py b/app/plugins/builtins/unified_search/plugin.py index faea81f..f6feafc 100644 --- a/app/plugins/builtins/unified_search/plugin.py +++ b/app/plugins/builtins/unified_search/plugin.py @@ -6,7 +6,7 @@ import logging from typing import Any from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendPageRoute logger = logging.getLogger(__name__) @@ -40,6 +40,9 @@ class UnifiedSearchPlugin(BasePlugin): migrations=["0001_initial.sql", "0002_embeddings.sql"], permissions=["search:read", "search:admin"], is_core=False, + page_routes=[ + FrontendPageRoute(path='/search', component='@/pages/GlobalSearchResults', protected=True), + ], ) async def on_activate(self, db, service_container, event_bus) -> None: diff --git a/app/plugins/manifest.py b/app/plugins/manifest.py index 379fba6..936b8ed 100644 --- a/app/plugins/manifest.py +++ b/app/plugins/manifest.py @@ -24,6 +24,74 @@ class FieldDefinition(BaseModel): sensitivity: str = Field(default="normal", description="normal|sensitive|critical") +# ── Frontend UI definition models (Phase 3) ────────────────────────────────── + + +class FrontendMenuItem(BaseModel): + """A sidebar navigation item contributed by a plugin.""" + + label_key: str = Field(..., description="i18n key for the menu label") + label: str = Field(default="", description="Fallback label if i18n key is missing") + path: str = Field(..., description="Frontend route path, e.g. /mail") + icon: str = Field(default="FileText", description="lucide-react icon name") + group: str = Field(default="", description="Optional group label_key for tree-style nesting") + order: int = Field(default=100, description="Sort order within the sidebar") + badge_key: str = Field(default="", description="Optional store key for badge count") + + +class FrontendPageRoute(BaseModel): + """A frontend page route contributed by a plugin.""" + + path: str = Field(..., description="Frontend route path, e.g. /mail or /mail/settings") + component: str = Field( + ..., description="Dotted path to the React component, e.g. @/pages/Mail" + ) + parent: str = Field( + default="", + description="Parent route path for nested routes (e.g. /settings for a settings sub-page)", + ) + protected: bool = Field(default=True, description="Whether the route requires authentication") + order: int = Field(default=100, description="Sort order") + + +class FrontendDetailTab(BaseModel): + """A detail tab contributed by a plugin for entity detail views.""" + + entity_type: str = Field(..., description="Entity type this tab applies to, e.g. 'contact'") + label_key: str = Field(..., description="i18n key for the tab label") + label: str = Field(default="", description="Fallback label") + component: str = Field(..., description="Dotted path to the React component") + icon: str = Field(default="FileText", description="lucide-react icon name") + order: int = Field(default=100, description="Sort order within the detail view") + permission: str = Field(default="", description="Optional permission required to see this tab") + + +class FrontendSettingsPage(BaseModel): + """A settings sub-page contributed by a plugin.""" + + path: str = Field(..., description="Settings sub-route path, e.g. mail or notifications") + label_key: str = Field(..., description="i18n key for the settings nav label") + label: str = Field(default="", description="Fallback label") + component: str = Field(..., description="Dotted path to the React component") + icon: str = Field(default="Settings", description="lucide-react icon name") + order: int = Field(default=100, description="Sort order within settings nav") + permission: str = Field(default="", description="Optional permission required") + + +class FrontendDashboardWidget(BaseModel): + """A dashboard widget contributed by a plugin.""" + + id: str = Field(..., description="Unique widget identifier") + label_key: str = Field(..., description="i18n key for the widget title") + label: str = Field(default="", description="Fallback label") + component: str = Field(..., description="Dotted path to the React component") + icon: str = Field(default="LayoutDashboard", description="lucide-react icon name") + order: int = Field(default=100, description="Sort order on the dashboard") + col_span: int = Field(default=1, description="Grid column span (1-4)") + row_span: int = Field(default=1, description="Grid row span") + permission: str = Field(default="", description="Optional permission required") + + class PluginManifest(BaseModel): """Manifest describing a plugin's metadata, dependencies, and capabilities.""" @@ -58,6 +126,22 @@ class PluginManifest(BaseModel): default_factory=list, description="AI agent capabilities this plugin provides (e.g. 'contact_search', 'email_draft')", ) + # ── Frontend UI contributions (Phase 3) ── + menu_items: list[FrontendMenuItem] = Field( + default_factory=list, description="Sidebar navigation items contributed by this plugin" + ) + page_routes: list[FrontendPageRoute] = Field( + default_factory=list, description="Frontend page routes contributed by this plugin" + ) + detail_tabs: list[FrontendDetailTab] = Field( + default_factory=list, description="Entity detail tabs contributed by this plugin" + ) + settings_pages: list[FrontendSettingsPage] = Field( + default_factory=list, description="Settings sub-pages contributed by this plugin" + ) + dashboard_widgets: list[FrontendDashboardWidget] = Field( + default_factory=list, description="Dashboard widgets contributed by this plugin" + ) @field_validator("name") @classmethod @@ -120,6 +204,31 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse( "required": "false", "description": "Required permissions", }, + "menu_items": { + "type": "list[FrontendMenuItem]", + "required": "false", + "description": "Sidebar navigation items (label_key, path, icon, group, order)", + }, + "page_routes": { + "type": "list[FrontendPageRoute]", + "required": "false", + "description": "Frontend page routes (path, component, parent, protected)", + }, + "detail_tabs": { + "type": "list[FrontendDetailTab]", + "required": "false", + "description": "Entity detail tabs (entity_type, label_key, component, icon, permission)", + }, + "settings_pages": { + "type": "list[FrontendSettingsPage]", + "required": "false", + "description": "Settings sub-pages (path, label_key, component, icon, permission)", + }, + "dashboard_widgets": { + "type": "list[FrontendDashboardWidget]", + "required": "false", + "description": "Dashboard widgets (id, label_key, component, col_span, permission)", + }, }, example=PluginManifest( name="example_plugin", @@ -131,5 +240,21 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse( events=["contact.created"], migrations=["0001_initial.sql"], permissions=["contacts.read"], + menu_items=[ + FrontendMenuItem( + label_key="nav.examplePlugin", + label="Example", + path="/example", + icon="Sparkles", + order=50, + ) + ], + page_routes=[ + FrontendPageRoute( + path="/example", + component="@/pages/Example", + protected=True, + ) + ], ), ) diff --git a/app/plugins/registry.py b/app/plugins/registry.py index 879ae37..dca683f 100644 --- a/app/plugins/registry.py +++ b/app/plugins/registry.py @@ -671,6 +671,37 @@ class PluginRegistry: return plugins_list + # ── Active UI Manifests (Phase 3) ── + + async def get_active_manifests(self, db: AsyncSession) -> list[dict[str, Any]]: + """Return UI manifests for all active plugins. + + Each entry contains the plugin name and its frontend UI contributions + (menu_items, page_routes, detail_tabs, settings_pages, dashboard_widgets). + """ + result = await db.execute(select(PluginModel).where(PluginModel.active.is_(True))) + active_records = {row.name: row for row in result.scalars().all()} + + manifests: list[dict[str, Any]] = [] + for name, plugin in self._plugins.items(): + if name not in active_records: + continue + m = plugin.manifest + manifests.append( + { + "name": m.name, + "display_name": m.display_name, + "version": m.version, + "is_core": m.is_core, + "menu_items": [item.model_dump() for item in m.menu_items], + "page_routes": [route.model_dump() for route in m.page_routes], + "detail_tabs": [tab.model_dump() for tab in m.detail_tabs], + "settings_pages": [page.model_dump() for page in m.settings_pages], + "dashboard_widgets": [widget.model_dump() for widget in m.dashboard_widgets], + } + ) + return manifests + # ── Internal Helpers ── async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None: diff --git a/app/routes/plugins.py b/app/routes/plugins.py index 4f84d88..e41bafd 100644 --- a/app/routes/plugins.py +++ b/app/routes/plugins.py @@ -1,16 +1,31 @@ -"""Plugin routes — list, install, activate, deactivate, uninstall, manifest schema, config.""" +"""Plugin routes — list, install, activate, deactivate, uninstall, manifest schema, config, upload, install-url.""" from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query +import importlib +import logging +import os +import re +import shutil +import tempfile +import zipfile +from pathlib import Path +from typing import Any + +import httpx +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import require_permission +from app.plugins.base import BasePlugin +from app.plugins.manifest import PluginManifest from app.plugins.migration_runner import MigrationValidationError from app.services.plugin_service import get_plugin_service +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"]) @@ -19,6 +34,14 @@ class PluginConfigUpdate(BaseModel): config: dict +class PluginUrlInstall(BaseModel): + """Request body for installing a plugin from a URL.""" + url: str + + +MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB + + @router.get("") async def list_plugins( db: AsyncSession = Depends(get_db), @@ -39,6 +62,23 @@ async def get_manifest_schema( return service.get_manifest_schema() +@router.get("/active-manifests") +async def get_active_manifests( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("plugins:read")), +): + """Get UI manifests for all active plugins. + + Returns menu_items, page_routes, detail_tabs, settings_pages, and + dashboard_widgets contributed by each active plugin. Used by the + frontend PluginRegistry to dynamically register routes, sidebar items, + settings pages, and detail tabs. + """ + service = get_plugin_service() + manifests = await service.get_active_manifests(db) + return {"plugins": manifests, "total": len(manifests)} + + @router.get("/{name}/config") async def get_plugin_config( name: str, @@ -190,9 +230,9 @@ async def uninstall_plugin( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("plugins:configure")), ): - """Uninstall a plugin. Optionally drop plugin-created tables with remove_data=true. + """Uninstall a plugin by name. Deactivates, drops tables, removes DB record. - Returns 200 with uninstalled status. Idempotent for already-uninstalled plugins. + Idempotent: returns 200 if already uninstalled. """ import uuid as uuid_mod @@ -212,3 +252,407 @@ async def uninstall_plugin( 404, detail={"detail": str(exc), "code": "plugin_not_found"} ) from None raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) from None + + +# ── Plugin Upload / URL Install ────────────────────────────────────────────── + + +def _validate_manifest_name(name: str) -> str: + """Validate plugin name is alphanumeric with underscores only.""" + if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name): + raise ValueError( + f"Invalid plugin name '{name}': must start with a letter and contain only " + f"alphanumeric characters and underscores" + ) + return name + + +def _check_dangerous_imports(source_code: str) -> list[str]: + """Check plugin source for dangerous imports/patterns. + + Returns a list of dangerous patterns found (empty if safe). + """ + dangerous_patterns = [ + (r"\bos\.system\b", "os.system call"), + (r"\bsubprocess\.", "subprocess module"), + (r"\beval\s*\(", "eval() call"), + (r"\bexec\s*\(", "exec() call"), + (r"\b__import__\s*\(", "__import__() call"), + (r"\bcompile\s*\(", "compile() call"), + ] + found: list[str] = [] + for pattern, description in dangerous_patterns: + if re.search(pattern, source_code): + found.append(description) + return found + + +def _check_migration_sql(sql_content: str) -> list[str]: + """Basic SQL validation for migration files. + + Returns a list of issues found (empty if OK). + """ + issues: list[str] = [] + # Check for basic SQL syntax issues + lines = sql_content.strip().split("\n") + for i, line in enumerate(lines, 1): + stripped = line.strip() + if not stripped or stripped.startswith("--"): + continue + # Check for unclosed parentheses + if stripped.count("(") != stripped.count(")"): + issues.append(f"Line {i}: unbalanced parentheses") + # Check for DROP TABLE (dangerous in migrations) + if re.search(r"\bDROP\s+TABLE\b", stripped, re.IGNORECASE): + issues.append(f"Line {i}: DROP TABLE is not allowed in plugin migrations") + return issues + + +def _find_plugin_class_in_module(module: Any) -> type[BasePlugin] | None: + """Find a BasePlugin subclass in a module.""" + for attr_name in dir(module): + attr = getattr(module, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, BasePlugin) + and attr is not BasePlugin + ): + return attr + return None + + +def _extract_plugin_from_zip(zip_path: str) -> tuple[Path, str, type[BasePlugin]]: + """Extract a ZIP file and find the plugin class. + + Returns (extract_dir, plugin_name, plugin_class). + """ + extract_dir = Path(tempfile.mkdtemp(prefix="plugin_upload_")) + try: + with zipfile.ZipFile(zip_path, "r") as zf: + # Validate ZIP + bad_files = [f for f in zf.namelist() if f.startswith("..") or f.startswith("/")] + if bad_files: + raise ValueError(f"ZIP contains files with unsafe paths: {bad_files}") + zf.extractall(extract_dir) + + # Find plugin.py in the extracted contents + plugin_py_path: Path | None = None + for fpath in extract_dir.rglob("plugin.py"): + plugin_py_path = fpath + break + + if plugin_py_path is None: + raise ValueError("ZIP does not contain a plugin.py file") + + # Import the module dynamically + spec = importlib.util.spec_from_file_location( + "uploaded_plugin", plugin_py_path + ) + if spec is None or spec.loader is None: + raise ValueError("Could not load plugin.py module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Find BasePlugin subclass + plugin_class = _find_plugin_class_in_module(module) + if plugin_class is None: + raise ValueError( + "plugin.py does not contain a BasePlugin subclass" + ) + + plugin_instance = plugin_class() + plugin_name = plugin_instance.name + + # Validate manifest + manifest = plugin_instance.manifest + if not manifest.name or not manifest.version or not manifest.display_name: + raise ValueError( + "Plugin manifest must include name, version, and display_name" + ) + + # Validate plugin name + _validate_manifest_name(manifest.name) + + # Check for dangerous imports in plugin.py + source_code = plugin_py_path.read_text(encoding="utf-8") + dangerous = _check_dangerous_imports(source_code) + if dangerous: + raise ValueError( + f"Plugin contains dangerous patterns: {', '.join(dangerous)}" + ) + + # Check migration SQL files + migrations_dir = plugin_py_path.parent / "migrations" + if migrations_dir.exists(): + for sql_file in sorted(migrations_dir.glob("*.sql")): + sql_content = sql_file.read_text(encoding="utf-8") + issues = _check_migration_sql(sql_content) + if issues: + raise ValueError( + f"Migration file {sql_file.name} has issues: {'; '.join(issues)}" + ) + + return extract_dir, plugin_name, plugin_class + + except Exception: + # Clean up on failure + shutil.rmtree(extract_dir, ignore_errors=True) + raise + + +def _install_plugin_from_dir( + extract_dir: Path, + plugin_name: str, + plugin_class: type[BasePlugin], +) -> None: + """Copy plugin directory to builtins and register it. + + Copies the extracted plugin directory to app/plugins/builtins/{plugin_name}/. + """ + builtins_dir = Path(__file__).parent.parent / "plugins" / "builtins" / plugin_name + builtins_dir.mkdir(parents=True, exist_ok=True) + + # Copy all files from extract_dir to builtins_dir + for item in extract_dir.iterdir(): + dest = builtins_dir / item.name + if item.is_dir(): + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(item, dest) + else: + shutil.copy2(item, dest) + + # Register the plugin in the registry + registry = get_plugin_service().registry + instance = plugin_class() + registry.register_plugin(instance) + + logger.info( + "Installed plugin '%s' from uploaded ZIP to %s", + plugin_name, + builtins_dir, + ) + + +@router.post("/upload") +async def upload_plugin( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("plugins:configure")), +): + """Upload and install a plugin from a ZIP file. + + The ZIP must contain a plugin directory with a plugin.py that defines a BasePlugin subclass. + Validates the manifest, checks for conflicts, runs migrations, and installs the plugin. + """ + import uuid as uuid_mod + + # Validate file is a ZIP + if not file.filename or not file.filename.endswith(".zip"): + raise HTTPException(400, detail={"detail": "File must be a .zip archive", "code": "invalid_file"}) + + # Check file size + contents = await file.read() + if len(contents) > MAX_UPLOAD_SIZE: + raise HTTPException( + 413, + detail={ + "detail": f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024*1024)} MB", + "code": "file_too_large", + }, + ) + + # Write to temp file + tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") + try: + tmp_zip.write(contents) + tmp_zip.close() + + # Extract and validate + extract_dir, plugin_name, plugin_class = _extract_plugin_from_zip(tmp_zip.name) + + # Check for name conflicts with existing plugins + service = get_plugin_service() + existing_plugins = await service.list_plugins(db) + existing_names = {p["name"] for p in existing_plugins} + + if plugin_name in existing_names: + # Check if version is higher + existing_plugin = next( + (p for p in existing_plugins if p["name"] == plugin_name), None + ) + if existing_plugin: + raise HTTPException( + 409, + detail={ + "detail": f"Plugin '{plugin_name}' already exists (version {existing_plugin.get('version', 'unknown')}). " + f"Uninstall the existing plugin first or upload a higher version.", + "code": "plugin_exists", + }, + ) + + # Install the plugin directory + _install_plugin_from_dir(extract_dir, plugin_name, plugin_class) + + # Run migrations and install via service + result = await service.install_plugin( + db, + plugin_name, + tenant_id=uuid_mod.UUID(current_user["tenant_id"]), + user_id=uuid_mod.UUID(current_user["user_id"]), + ) + + # Log audit + from app.core.audit import log_audit + await log_audit( + db, + uuid_mod.UUID(current_user["tenant_id"]), + uuid_mod.UUID(current_user["user_id"]), + action="plugin.upload", + entity_type="plugin", + changes={"name": plugin_name, "version": result.get("version"), "method": "upload"}, + ) + + return { + **result, + "message": f"Plugin '{plugin_name}' uploaded and installed successfully", + } + + except ValueError as exc: + raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_validation_error"}) from None + except MigrationValidationError as exc: + raise HTTPException( + 422, detail={"detail": str(exc), "code": "migration_validation_error"} + ) from None + except Exception as exc: + logger.exception("Failed to upload plugin") + raise HTTPException( + 500, detail={"detail": f"Failed to install plugin: {str(exc)}", "code": "install_error"} + ) from None + finally: + # Clean up temp files + try: + os.unlink(tmp_zip.name) + except Exception: + pass + try: + if "extract_dir" in dir(): + shutil.rmtree(extract_dir, ignore_errors=True) + except Exception: + pass + + +@router.post("/install-url") +async def install_plugin_from_url( + body: PluginUrlInstall, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("plugins:configure")), +): + """Install a plugin from a URL (downloads ZIP and installs).""" + import uuid as uuid_mod + + if not body.url: + raise HTTPException(400, detail={"detail": "URL is required", "code": "missing_url"}) + + # Download ZIP from URL + tmp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(body.url, follow_redirects=True) + response.raise_for_status() + + content = response.content + if len(content) > MAX_UPLOAD_SIZE: + raise HTTPException( + 413, + detail={ + "detail": f"Downloaded file too large. Maximum size is {MAX_UPLOAD_SIZE // (1024*1024)} MB", + "code": "file_too_large", + }, + ) + + tmp_zip.write(content) + tmp_zip.close() + + # Extract and validate + extract_dir, plugin_name, plugin_class = _extract_plugin_from_zip(tmp_zip.name) + + # Check for name conflicts + service = get_plugin_service() + existing_plugins = await service.list_plugins(db) + existing_names = {p["name"] for p in existing_plugins} + + if plugin_name in existing_names: + raise HTTPException( + 409, + detail={ + "detail": f"Plugin '{plugin_name}' already exists. Uninstall the existing plugin first.", + "code": "plugin_exists", + }, + ) + + # Install the plugin directory + _install_plugin_from_dir(extract_dir, plugin_name, plugin_class) + + # Run migrations and install via service + result = await service.install_plugin( + db, + plugin_name, + tenant_id=uuid_mod.UUID(current_user["tenant_id"]), + user_id=uuid_mod.UUID(current_user["user_id"]), + ) + + # Log audit + from app.core.audit import log_audit + await log_audit( + db, + uuid_mod.UUID(current_user["tenant_id"]), + uuid_mod.UUID(current_user["user_id"]), + action="plugin.install_url", + entity_type="plugin", + changes={"name": plugin_name, "version": result.get("version"), "url": body.url}, + ) + + return { + **result, + "message": f"Plugin '{plugin_name}' downloaded and installed successfully", + } + + except httpx.HTTPStatusError as exc: + raise HTTPException( + 400, + detail={ + "detail": f"Failed to download plugin from URL: HTTP {exc.response.status_code}", + "code": "download_error", + }, + ) from None + except httpx.RequestError as exc: + raise HTTPException( + 400, + detail={ + "detail": f"Failed to download plugin from URL: {str(exc)}", + "code": "download_error", + }, + ) from None + except ValueError as exc: + raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_validation_error"}) from None + except MigrationValidationError as exc: + raise HTTPException( + 422, detail={"detail": str(exc), "code": "migration_validation_error"} + ) from None + except Exception as exc: + logger.exception("Failed to install plugin from URL") + raise HTTPException( + 500, detail={"detail": f"Failed to install plugin: {str(exc)}", "code": "install_error"} + ) from None + finally: + # Clean up temp files + try: + os.unlink(tmp_zip.name) + except Exception: + pass + try: + if "extract_dir" in dir(): + shutil.rmtree(extract_dir, ignore_errors=True) + except Exception: + pass diff --git a/app/services/plugin_service.py b/app/services/plugin_service.py index 63948fa..edc2c75 100644 --- a/app/services/plugin_service.py +++ b/app/services/plugin_service.py @@ -261,6 +261,10 @@ class PluginService: return MANIFEST_SCHEMA_DOC.model_dump() + async def get_active_manifests(self, db: AsyncSession) -> list[dict[str, Any]]: + """Return UI manifests for all active plugins.""" + return await self._registry.get_active_manifests(db) + # Global service instance _service: PluginService | None = None diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index 31d8890..0bb4bac 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -1,43 +1,296 @@ # LeoCRM Plugin Development Guide -> **Version:** 1.0 -> **Datum:** 2026-07-23 -> **Gültig für:** Alle Plugin-Entwickler +> **Version:** 2.0 +> **Date:** 2026-07-23 +> **Applies to:** All plugin developers --- -## 1. Plugin-Struktur +## 1. Overview -Jedes Plugin liegt unter `app/plugins/builtins//`: +Plugins are self-contained modules that extend LeoCRM's functionality. Each plugin lives in its own directory under `app/plugins/builtins/` and declares its capabilities via a `PluginManifest`. The plugin system supports: + +- **API Routes** — Register FastAPI route handlers +- **Event Subscriptions** — React to domain events (contact.created, etc.) +- **Database Migrations** — Versioned SQL migrations run on install +- **RBAC Permissions** — Declare and enforce permissions +- **Frontend UI Contributions** — Menu items, page routes, detail tabs, settings pages, dashboard widgets +- **AI Agent Capabilities** — Register tools for the AI assistant +- **Lifecycle Hooks** — on_install, on_activate, on_deactivate, on_uninstall + +--- + +## 2. Plugin Structure + +Every plugin lives under `app/plugins/builtins//`: ``` app/plugins/builtins/my_plugin/ -├── __init__.py -├── plugin.py # Plugin-Klasse mit Manifest -├── routes.py # API-Routes -├── models.py # SQLAlchemy-Modelle (optional) -├── schemas.py # Pydantic-Schemas (optional) -├── services.py # Business-Logik (optional) -├── migrations/ # SQL-Migrationen +├── __init__.py # Package init (can be empty) +├── plugin.py # Plugin class with manifest (required) +├── routes.py # FastAPI route definitions +├── models.py # SQLAlchemy models (optional) +├── schemas.py # Pydantic schemas (optional) +├── services.py # Business logic (optional) +├── migrations/ # SQL migration files │ └── 0001_initial.sql -└── tests/ # Plugin-Tests (optional) +└── tests/ # Plugin tests (optional) + ├── __init__.py + └── test_plugin.py ``` -## 2. Plugin-Manifest +--- -Das Manifest definiert Metadaten, Abhängigkeiten, Routes, Events und Permissions: +## 3. Manifest Format + +The `PluginManifest` is a Pydantic v2 model that declares all metadata and capabilities of a plugin. It is defined in `app/plugins/manifest.py`. + +### 3.1 Core Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | `str` | Yes | Unique plugin identifier (snake_case, max 80 chars, alphanumeric with underscores only) | +| `version` | `str` | Yes | Semantic version string (e.g. "1.0.0") | +| `display_name` | `str` | Yes | Human-readable plugin name (max 120 chars) | +| `description` | `str` | No | Plugin description (max 500 chars) | +| `dependencies` | `list[str]` | No | Other plugin names this plugin depends on | +| `is_core` | `bool` | No | Whether this is a core plugin that cannot be deactivated (default: `false`) | + +### 3.2 Route Definitions + +```python +from app.plugins.manifest import PluginRouteDef + +routes=[ + PluginRouteDef( + path="/api/v1/my-plugin", # URL path prefix + module="app.plugins.builtins.my_plugin.routes", # Dotted module path + router_attr="router", # Attribute name of the APIRouter in the module + ), +] +``` + +### 3.3 Event Subscriptions + +```python +events=[ + "contact.created", + "contact.updated", + "contact.deleted", +] +``` + +Event naming convention: `.` (e.g. `company.created`, `task.assigned`). + +### 3.4 Migrations + +```python +migrations=[ + "0001_initial.sql", + "0002_add_indexes.sql", +] +``` + +Migration files are stored in the plugin's `migrations/` directory and run in order on install. + +### 3.5 Permissions + +```python +permissions=[ + "my_plugin:read", + "my_plugin:write", + "my_plugin:delete", + "my_plugin:admin", +] +``` + +Permission naming convention: `:`. + +### 3.6 Field Definitions (Field-Level Permissions) + +```python +from app.plugins.manifest import FieldDefinition + +field_definitions=[ + FieldDefinition( + module="companies", # Module name (e.g. 'companies', 'contacts') + field="annual_revenue", # Field name + label="Annual Revenue", # Human-readable label + sensitivity="sensitive", # normal|sensitive|critical + ), +] +``` + +### 3.7 Agent Capabilities + +```python +agent_capabilities=[ + "contact_search", # Contact search capability + "email_draft", # Email draft generation + "calendar_scheduling", # Calendar scheduling +] +``` + +### 3.8 Frontend UI Fields (Phase 3) + +#### FrontendMenuItem — Sidebar Navigation + +```python +from app.plugins.manifest import FrontendMenuItem + +menu_items=[ + FrontendMenuItem( + label_key="nav.myPlugin", # i18n key for the menu label + label="My Plugin", # Fallback label if i18n key is missing + path="/my-plugin", # Frontend route path + icon="Sparkles", # lucide-react icon name + group="", # Optional group label_key for tree-style nesting + order=100, # Sort order within the sidebar + badge_key="", # Optional store key for badge count + ), +] +``` + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `label_key` | `str` | Yes | — | i18n key for the menu label | +| `label` | `str` | No | `""` | Fallback label if i18n key is missing | +| `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` | +| `icon` | `str` | No | `"FileText"` | lucide-react icon name | +| `group` | `str` | No | `""` | Optional group label_key for tree-style nesting | +| `order` | `int` | No | `100` | Sort order within the sidebar | +| `badge_key` | `str` | No | `""` | Optional store key for badge count | + +#### FrontendPageRoute — Page Routes + +```python +from app.plugins.manifest import FrontendPageRoute + +page_routes=[ + FrontendPageRoute( + path="/my-plugin", # Frontend route path + component="@/pages/MyPlugin", # Dotted path to the React component + parent="", # Parent route path for nested routes + protected=True, # Whether the route requires authentication + order=100, # Sort order + ), +] +``` + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` or `/mail/settings` | +| `component` | `str` | Yes | — | Dotted path to the React component, e.g. `@/pages/Mail` | +| `parent` | `str` | No | `""` | Parent route path for nested routes (e.g. `/settings` for a settings sub-page) | +| `protected` | `bool` | No | `True` | Whether the route requires authentication | +| `order` | `int` | No | `100` | Sort order | + +#### FrontendDetailTab — Entity Detail Tabs + +```python +from app.plugins.manifest import FrontendDetailTab + +detail_tabs=[ + FrontendDetailTab( + entity_type="contact", # Entity type this tab applies to + label_key="tabs.myPlugin", # i18n key for the tab label + label="My Tab", # Fallback label + component="@/components/MyTab", # Dotted path to the React component + icon="FileText", # lucide-react icon name + order=50, # Sort order within the detail view + permission="my_plugin:read", # Optional permission required to see this tab + ), +] +``` + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `entity_type` | `str` | Yes | — | Entity type this tab applies to, e.g. `'contact'` | +| `label_key` | `str` | Yes | — | i18n key for the tab label | +| `label` | `str` | No | `""` | Fallback label | +| `component` | `str` | Yes | — | Dotted path to the React component | +| `icon` | `str` | No | `"FileText"` | lucide-react icon name | +| `order` | `int` | No | `100` | Sort order within the detail view | +| `permission` | `str` | No | `""` | Optional permission required to see this tab | + +#### FrontendSettingsPage — Settings Sub-Pages + +```python +from app.plugins.manifest import FrontendSettingsPage + +settings_pages=[ + FrontendSettingsPage( + path="my-plugin", # Settings sub-route path + label_key="settings.myPlugin", # i18n key for the settings nav label + label="My Plugin", # Fallback label + component="@/pages/MyPluginSettings", # Dotted path to the React component + icon="Sparkles", # lucide-react icon name + order=100, # Sort order within settings nav + permission="my_plugin:admin", # Optional permission required + ), +] +``` + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `path` | `str` | Yes | — | Settings sub-route path, e.g. `mail` or `notifications` | +| `label_key` | `str` | Yes | — | i18n key for the settings nav label | +| `label` | `str` | No | `""` | Fallback label | +| `component` | `str` | Yes | — | Dotted path to the React component | +| `icon` | `str` | No | `"Settings"` | lucide-react icon name | +| `order` | `int` | No | `100` | Sort order within settings nav | +| `permission` | `str` | No | `""` | Optional permission required | + +#### FrontendDashboardWidget — Dashboard Widgets + +```python +from app.plugins.manifest import FrontendDashboardWidget + +dashboard_widgets=[ + FrontendDashboardWidget( + id="my_plugin_stats", # Unique widget identifier + label_key="widgets.myPlugin", # i18n key for the widget title + label="My Plugin Stats", # Fallback label + component="@/components/MyWidget", # Dotted path to the React component + icon="LayoutDashboard", # lucide-react icon name + order=100, # Sort order on the dashboard + col_span=1, # Grid column span (1-4) + row_span=1, # Grid row span + permission="my_plugin:read", # Optional permission required + ), +] +``` + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `id` | `str` | Yes | — | Unique widget identifier | +| `label_key` | `str` | Yes | — | i18n key for the widget title | +| `label` | `str` | No | `""` | Fallback label | +| `component` | `str` | Yes | — | Dotted path to the React component | +| `icon` | `str` | No | `"LayoutDashboard"` | lucide-react icon name | +| `order` | `int` | No | `100` | Sort order on the dashboard | +| `col_span` | `int` | No | `1` | Grid column span (1-4) | +| `row_span` | `int` | No | `1` | Grid row span | +| `permission` | `str` | No | `""` | Optional permission required | + +### 3.9 Complete Manifest Example ```python from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import ( + PluginManifest, PluginRouteDef, FieldDefinition, + FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, + FrontendSettingsPage, FrontendDashboardWidget, +) class MyPlugin(BasePlugin): manifest = PluginManifest( name="my_plugin", version="1.0.0", display_name="My Plugin", - description="Description of what the plugin does.", - dependencies=["permissions"], # Other plugins this depends on + description="A comprehensive example plugin.", + dependencies=["permissions"], + is_core=False, routes=[ PluginRouteDef( path="/api/v1/my-plugin", @@ -47,40 +300,280 @@ class MyPlugin(BasePlugin): ], events=["contact.created", "contact.updated"], migrations=["0001_initial.sql"], - permissions=[ - "my_plugin:read", - "my_plugin:write", - "my_plugin:delete", + permissions=["my_plugin:read", "my_plugin:write", "my_plugin:admin"], + field_definitions=[ + FieldDefinition( + module="contacts", + field="custom_field", + label="Custom Field", + sensitivity="normal", + ), ], - agent_capabilities=[ - "my_plugin:search", - "my_plugin:analyze", + agent_capabilities=["my_plugin:search"], + menu_items=[ + FrontendMenuItem( + label_key="nav.myPlugin", + label="My Plugin", + path="/my-plugin", + icon="Sparkles", + order=100, + ), + ], + page_routes=[ + FrontendPageRoute( + path="/my-plugin", + component="@/pages/MyPlugin", + protected=True, + ), + ], + detail_tabs=[ + FrontendDetailTab( + entity_type="contact", + label_key="tabs.myPlugin", + label="My Tab", + component="@/components/MyTab", + icon="FileText", + order=50, + permission="my_plugin:read", + ), + ], + settings_pages=[ + FrontendSettingsPage( + path="my-plugin", + label_key="settings.myPlugin", + label="My Plugin", + component="@/pages/MyPluginSettings", + icon="Sparkles", + order=100, + permission="my_plugin:admin", + ), + ], + dashboard_widgets=[ + FrontendDashboardWidget( + id="my_plugin_stats", + label_key="widgets.myPlugin", + label="My Plugin Stats", + component="@/components/MyWidget", + icon="LayoutDashboard", + order=100, + col_span=2, + row_span=1, + permission="my_plugin:read", + ), ], ) ``` -### Manifest-Felder +--- -| Feld | Typ | Pflicht | Beschreibung | -|---|---|---|---| -| `name` | `str` | Ja | Eindeutiger Plugin-Name (snake_case) | -| `version` | `str` | Ja | Semantic Version | -| `display_name` | `str` | Ja | Anzeigename | -| `description` | `str` | Nein | Kurzbeschreibung | -| `dependencies` | `list[str]` | Nein | Andere Plugins, die geladen sein müssen | -| `routes` | `list[PluginRouteDef]` | Nein | API-Routen-Definitionen | -| `events` | `list[str]` | Nein | Events, die das Plugin abonniert | -| `migrations` | `list[str]` | Nein | SQL-Migrationsdateien | -| `permissions` | `list[str]` | Nein | RBAC-Permissions, die das Plugin definiert | -| `field_definitions` | `list[FieldDefinition]` | Nein | Feld-Level-Permissions | -| `agent_capabilities` | `list[str]` | Nein | KI-Agent-Fähigkeiten, die das Plugin bietet | -| `is_core` | `bool` | Nein | Core-Plugin (kann nicht deaktiviert werden) | +## 4. Plugin Lifecycle -## 3. RBAC-Permissions +### 4.1 Installation (`on_install`) -### Permissions definieren +Called when the plugin is installed, after migrations are run. Override to perform seed data or initial setup. -Im Manifest werden alle Permissions des Plugins aufgelistet: +```python +async def on_install(self, db: AsyncSession, service_container: ServiceContainer) -> None: + """Perform initial setup after migrations.""" + # Create default settings + settings_service = service_container.settings + await settings_service.create_defaults(db, plugin_name=self.name) +``` + +### 4.2 Activation (`on_activate`) + +Called when the plugin is activated. Override to register event listeners and prepare runtime state. The default implementation subscribes to events listed in the manifest. + +```python +async def on_activate( + self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus +) -> None: + """Register event listeners and prepare runtime state.""" + # Default: subscribes to manifest events + for event_name in self.manifest.events: + handler = self._make_event_handler(event_name) + self._event_handlers[event_name] = handler + event_bus.subscribe(event_name, handler) + self._container = service_container +``` + +### 4.3 Deactivation (`on_deactivate`) + +Called when the plugin is deactivated. Override to clean up runtime state. The default implementation unsubscribes all event listeners. + +```python +async def on_deactivate( + self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus +) -> None: + """Clean up runtime state.""" + for event_name, handler in self._event_handlers.items(): + event_bus.unsubscribe(event_name, handler) + self._event_handlers.clear() +``` + +### 4.4 Uninstallation (`on_uninstall`) + +Called when the plugin is uninstalled (before data tables are dropped). Override to clean up external resources. + +```python +async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None: + """Clean up external resources before tables are dropped.""" + # Remove external API webhooks, etc. + pass +``` + +--- + +## 5. UI Registration + +Plugins contribute frontend UI elements through their manifest. The frontend `PluginRegistry` component fetches active manifests and populates the `pluginStore` (Zustand), which is then consumed by: + +### 5.1 Sidebar Menu Items + +Menu items from all active plugins are merged and sorted by `order` via `getAllMenuItems()`. The sidebar renders them alongside built-in navigation items. + +### 5.2 Page Routes + +Page routes are registered via `PluginRouteRenderer`, a catch-all route handler that checks the current URL against all plugin `page_routes`. When a match is found, it renders the plugin's page component using `PluginPage` (React.lazy + Suspense + ErrorBoundary). + +### 5.3 Detail Tabs + +Detail tabs are filtered by `entity_type` via `getDetailTabsForEntity(entityType)`. Entity detail views (contacts, companies, etc.) render these tabs alongside built-in tabs. + +### 5.4 Settings Pages + +Settings pages are merged and sorted via `getAllSettingsPages()`. The settings navigation renders them alongside built-in settings pages. + +### 5.5 Dashboard Widgets + +Dashboard widgets are merged and sorted via `getAllDashboardWidgets()`. The dashboard grid renders them with their specified `col_span` and `row_span`. + +### 5.6 Frontend Component Resolution + +Component paths use the `@/` alias (resolved to `src/` by Vite). The `PluginPage` component converts `@/pages/MyPlugin` to `../pages/MyPlugin` for dynamic import: + +```typescript +// PluginLoader.tsx +const importPath = componentPath.replace(/^@\//, '../'); +const LazyComp = lazy(() => + import(/* @vite-ignore */ importPath).then((m) => ({ + default: m.default || m[Object.keys(m)[0]], + })) +); +``` + +--- + +## 6. Event Bus + +### 6.1 Subscribing to Events + +Events are declared in the manifest and handled by methods named `on_` with dots replaced by underscores: + +```python +# Manifest +events=["contact.created", "contact.updated"] + +# Handler methods +async def on_contact_created(self, event_data: dict): + contact_id = event_data.get("contact_id") + # React to new contact + +async def on_contact_updated(self, event_data: dict): + contact_id = event_data.get("contact_id") + # React to contact update +``` + +### 6.2 Publishing Events + +Events are published via the EventBus service: + +```python +from app.core.event_bus import get_event_bus + +event_bus = get_event_bus() +await event_bus.publish("contact.created", {"contact_id": str(contact.id)}) +``` + +### 6.3 Event Naming Conventions + +- Format: `.` +- Standard actions: `created`, `updated`, `deleted`, `assigned`, `completed` +- Examples: `company.created`, `task.assigned`, `email.sent` +- Use past tense for actions + +--- + +## 7. Migration Runner + +### 7.1 Writing Migrations + +SQL migration files are stored in the plugin's `migrations/` directory and referenced in the manifest: + +```sql +-- migrations/0001_initial.sql +CREATE TABLE my_plugin_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + name VARCHAR(200) NOT NULL, + config JSONB DEFAULT '{}', + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_my_plugin_items_tenant ON my_plugin_items(tenant_id); +``` + +### 7.2 Migration Naming + +- Files are named with a zero-padded sequence number and a descriptive slug: `0001_initial.sql`, `0002_add_indexes.sql` +- Migrations run in alphanumeric order +- Each migration runs exactly once per plugin installation + +### 7.3 Migration Guidelines + +- Always include `tenant_id` for multi-tenant tables +- Use `UUID` primary keys with `gen_random_uuid()` +- Include `created_at` and `updated_at` timestamps +- Add appropriate indexes for foreign keys and frequently queried columns +- Use `IF NOT EXISTS` / `IF EXISTS` for idempotent operations + +--- + +## 8. Service Container + +After activation, plugins can access shared services via `self.services`: + +```python +class MyPlugin(BasePlugin): + async def on_activate(self, db, service_container, event_bus): + self._container = service_container + + async def do_something(self): + # Access services after activation + settings = self.services.settings + audit = self.services.audit + cache = self.services.cache +``` + +Available services (defined in `app/core/service_container.py`): + +| Service | Accessor | Description | +|---|---|---| +| Settings | `self.services.settings` | Global and per-tenant settings | +| Audit | `self.services.audit` | Audit logging | +| Cache | `self.services.cache` | Redis/In-memory cache | +| EventBus | `self.services.event_bus` | Event publishing/subscribing | +| Notifications | `self.services.notifications` | User notifications | + +--- + +## 9. RBAC / Permissions + +### 9.1 Declaring Permissions + +Permissions are declared in the manifest: ```python permissions=[ @@ -88,12 +581,12 @@ permissions=[ "my_plugin:write", "my_plugin:delete", "my_plugin:admin", -], +] ``` -### Routes absichern +### 9.2 Securing Routes -Jede Route muss mit `require_permission` abgesichert werden: +Use the `require_permission` dependency: ```python from app.deps import get_current_user, require_permission @@ -106,72 +599,48 @@ async def list_items(current_user: dict = Depends(get_current_user)): @router.post("", status_code=201, dependencies=[Depends(require_permission("my_plugin:write"))]) async def create_item(data: ItemCreate, current_user: dict = Depends(get_current_user)): ... - -@router.delete("/{item_id}", dependencies=[Depends(require_permission("my_plugin:delete"))]) -async def delete_item(item_id: str, current_user: dict = Depends(get_current_user)): - ... ``` -### Permission-Namenskonvention +### 9.3 Permission Naming Convention - Format: `:` -- Standard-Actions: `read`, `write`, `delete`, `share`, `admin` -- Beispiele: `calendar:read`, `dms:write`, `tags:delete` +- Standard actions: `read`, `write`, `delete`, `share`, `admin` +- Examples: `calendar:read`, `dms:write`, `tags:delete` -## 4. KI-Agent-Framework +### 9.4 Field-Level Permissions -LeoCRM bietet ein integriertes KI-Agent-Framework basierend auf **LiteLLM** und **PydanticAI**. - -### Architektur - -``` -Plugin (ai_assistant, ai_proactive, zukünftige) - ↓ -LiteLLM (unified LLM interface — 100+ Provider) - ↓ -Provider (OpenAI, Anthropic, Google, Ollama, ...) - ↑ -Tool Registry (Plugin-Tools für KI-Agenten) -``` - -### LiteLLM — Unified LLM Interface - -LiteLLM bietet eine einheitliche API für über 100 LLM-Provider. Alle KI-Funktionen -in LeoCRM nutzen `litellm.acompletion()`: +Field definitions in the manifest enable field-level access control: ```python -import litellm - -response = await litellm.acompletion( - model="openai/gpt-4o", # oder anthropic/claude-3-sonnet, ollama/llama3 - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_query}, - ], - temperature=0.3, - max_tokens=1000, - api_key=os.environ.get("AI_API_KEY"), - api_base=os.environ.get("AI_API_BASE"), # optional für self-hosted -) -content = response.choices[0].message.content +field_definitions=[ + FieldDefinition( + module="companies", + field="annual_revenue", + label="Annual Revenue", + sensitivity="sensitive", # normal|sensitive|critical + ), +] ``` -### Konfiguration +--- -| Env-Var | Beschreibung | Standard | -|---|---|---| -| `AI_MODEL` | Modell-Name (z.B. `gpt-4o`, `claude-3-sonnet`, `llama3`) | — | -| `AI_API_KEY` | API-Key für den Provider | — | -| `AI_API_BASE` | Custom API-Base-URL (optional) | Provider-Standard | -| `AI_PROVIDER` | Provider-Präfix (`openai`, `anthropic`, `google`, `ollama`) | `openai` | +## 10. AI Agent Integration -Wenn `AI_MODEL` und `AI_API_KEY` nicht gesetzt sind, läuft der LLM-Client im Mock-Modus -(keyword-basierte Action-Mapping für Tests). +### 10.1 Agent Capabilities -### Tool Registry — KI-Tools registrieren +Declare AI capabilities in the manifest: -Plugins können Tools registrieren, die KI-Agenten während Chat-Sessions aufrufen können. -Jedes Tool deklariert Name, Beschreibung, JSON-Schema für Parameter und einen async Handler. +```python +agent_capabilities=[ + "contact_search", + "email_draft", + "calendar_scheduling", +] +``` + +### 10.2 Tool Registry + +Plugins can register tools for the AI assistant: ```python from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry @@ -196,94 +665,31 @@ registry.register( ) ``` -### Tool Handler - -Der Handler ist eine async Funktion, die Argumente und Kontext empfängt: +### 10.3 Tool Handler ```python async def my_search_handler(arguments: dict, context: dict) -> str: query = arguments.get("query", "") limit = arguments.get("limit", 10) - # ... perform search ... + # Perform search... return json.dumps({"results": results}) ``` -### Tools bei Plugin-Deaktivierung abmelden +### 10.4 Cleanup on Deactivation ```python -def on_deactivate(self): +async def on_deactivate(self, db, service_container, event_bus): registry = get_tool_registry() registry.unregister_plugin("my_plugin") ``` -### Agent Capabilities im Manifest +--- -Das `agent_capabilities` Feld im Manifest deklariert, welche KI-Fähigkeiten ein Plugin bietet: +## 11. Testing Guide -```python -agent_capabilities=[ - "contact_search", # Kontakt-Suche - "email_draft", # E-Mail-Entwürfe generieren - "calendar_scheduling", # Terminvorschläge -], -``` +### 11.1 Backend Tests -Diese Informationen werden vom AI Assistant verwendet, um Nutzern zu zeigen, -welche KI-Funktionen verfügbar sind. - -## 5. Events - -Plugins können Events abonnieren und auslösen: - -```python -# Im Manifest: -events=["contact.created", "contact.updated", "contact.deleted"] - -# Event-Handler im Plugin: -async def on_contact_created(self, event_data: dict): - # Reagiere auf neues Kontakt-Event - pass -``` - -Events werden vom Event-Publisher im Contact-Service ausgelöst: - -```python -from app.core.events import publish_event -await publish_event(db, "contact.created", {"contact_id": str(contact.id)}) -``` - -## 6. Datenbank-Migrationen - -SQL-Migrationen liegen unter `migrations/` im Plugin-Verzeichnis: - -```sql --- migrations/0001_initial.sql -CREATE TABLE my_plugin_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES tenants(id), - name VARCHAR(200) NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); -``` - -Im Manifest referenzieren: -```python -migrations=["0001_initial.sql"] -``` - -## 7. UI-Integration - -Siehe `docs/ui-design-guidelines.md` für Frontend-Konventionen. - -- Plugin-Seiten verwenden das 3-Spalten-Explorer-Layout -- PluginToolbar für Aktionen -- Plugin-Settings als eigene Settings-Sub-Seite -- i18n-Keys mit Plugin-Präfix - -## 8. Testing - -Tests liegen unter `tests/` im Plugin-Verzeichnis oder im zentralen `tests/` Ordner: +Tests live in the plugin's `tests/` directory or in the central `tests/` folder: ```python # tests/test_my_plugin.py @@ -301,48 +707,147 @@ async def test_list_items_without_permission_returns_403(client: AsyncClient, no assert response.status_code == 403 ``` -## 9. Plugin-Beispiel +### 11.2 Frontend Tests -Minimal-Beispiel für ein neues Plugin: +Frontend tests use vitest, @testing-library/react, and jsdom: + +```typescript +// frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { PluginRegistry } from '../PluginRegistry'; +import { usePluginStore } from '@/store/pluginStore'; + +// Mock the API hook +vi.mock('@/api/pluginManifests', () => ({ + useActivePluginManifests: () => ({ + data: { plugins: [/* ... */], total: 1 }, + isLoading: false, + error: null, + }), +})); + +describe('PluginRegistry', () => { + beforeEach(() => { + usePluginStore.getState().reset(); + }); + + it('populates store with manifests on mount', () => { + render(); + const manifests = usePluginStore.getState().manifests; + expect(manifests).toHaveLength(1); + }); +}); +``` + +--- + +## 12. Do's and Don'ts + +### Do's + +- **Do** use snake_case for plugin names +- **Do** declare all permissions in the manifest +- **Do** secure every API route with `require_permission` +- **Do** include `tenant_id` in all multi-tenant tables +- **Do** use UUID primary keys +- **Do** prefix i18n keys with the plugin name +- **Do** clean up resources in `on_deactivate` and `on_uninstall` +- **Do** write tests for both backend and frontend +- **Do** follow the event naming convention `.` +- **Do** use semantic versioning for plugin versions + +### Don'ts + +- **Don't** hardcode tenant IDs or user IDs +- **Don't** use synchronous database operations +- **Don't** store secrets in the database without encryption +- **Don't** modify other plugins' data directly (use events instead) +- **Don't** create circular dependencies between plugins +- **Don't** use `is_core=True` unless the plugin is essential for system operation +- **Don't** skip error handling in event handlers (they run asynchronously) +- **Don't** register the same route path in multiple plugins + +--- + +## 13. Examples + +### 13.1 Minimal Plugin ```python -# app/plugins/builtins/my_plugin/plugin.py +# app/plugins/builtins/minimal_example/plugin.py from app.plugins.base import BasePlugin from app.plugins.manifest import PluginManifest, PluginRouteDef -class MyPlugin(BasePlugin): +class MinimalExamplePlugin(BasePlugin): manifest = PluginManifest( - name="my_plugin", + name="minimal_example", version="1.0.0", - display_name="My Plugin", + display_name="Minimal Example", description="A minimal example plugin.", dependencies=[], routes=[ PluginRouteDef( - path="/api/v1/my-plugin", - module="app.plugins.builtins.my_plugin.routes", + path="/api/v1/minimal-example", + module="app.plugins.builtins.minimal_example.routes", router_attr="router", ), ], events=[], migrations=[], - permissions=["my_plugin:read", "my_plugin:write"], - agent_capabilities=[], + permissions=["minimal_example:read"], ) ``` ```python -# app/plugins/builtins/my_plugin/routes.py +# app/plugins/builtins/minimal_example/routes.py from fastapi import APIRouter, Depends from app.deps import get_current_user, require_permission router = APIRouter() -@router.get("", dependencies=[Depends(require_permission("my_plugin:read"))]) +@router.get("", dependencies=[Depends(require_permission("minimal_example:read"))]) async def list_items(current_user: dict = Depends(get_current_user)): return {"items": []} ``` +### 13.2 Plugin with UI + +See the complete manifest example in Section 3.9 for a plugin with full UI contributions (menu items, page routes, detail tabs, settings pages, dashboard widgets). + +### 13.3 Plugin with Events + +```python +# app/plugins/builtins/event_example/plugin.py +from app.plugins.base import BasePlugin +from app.plugins.manifest import PluginManifest + +class EventExamplePlugin(BasePlugin): + manifest = PluginManifest( + name="event_example", + version="1.0.0", + display_name="Event Example", + description="Demonstrates event handling.", + dependencies=[], + events=["contact.created", "contact.updated", "contact.deleted"], + migrations=[], + permissions=[], + ) + + async def on_contact_created(self, event_data: dict): + contact_id = event_data.get("contact_id") + print(f"Contact created: {contact_id}") + + async def on_contact_updated(self, event_data: dict): + contact_id = event_data.get("contact_id") + print(f"Contact updated: {contact_id}") + + async def on_contact_deleted(self, event_data: dict): + contact_id = event_data.get("contact_id") + print(f"Contact deleted: {contact_id}") +``` + --- -*Dieses Dokument ist verbindlich für alle Plugin-Entwicklung an LeoCRM.* +*This document is authoritative for all plugin development at LeoCRM.* diff --git a/frontend/src/api/pluginManifests.ts b/frontend/src/api/pluginManifests.ts new file mode 100644 index 0000000..8187dd9 --- /dev/null +++ b/frontend/src/api/pluginManifests.ts @@ -0,0 +1,15 @@ +import { apiGet } from './client'; +import { useQuery } from '@tanstack/react-query'; +import type { PluginUiManifest } from '../store/pluginStore'; + +export function useActivePluginManifests() { + return useQuery({ + queryKey: ['plugin-active-manifests'], + queryFn: async () => { + const res = await apiGet<{ plugins: PluginUiManifest[]; total: number }>('/plugins/active-manifests'); + return res; + }, + staleTime: 5 * 60 * 1000, // 5 min + refetchOnMount: true, + }); +} diff --git a/frontend/src/api/plugins.ts b/frontend/src/api/plugins.ts index 4b9ea43..fd3b3e7 100644 --- a/frontend/src/api/plugins.ts +++ b/frontend/src/api/plugins.ts @@ -1,5 +1,5 @@ /** - * Plugin management hooks: list, install, activate, deactivate, uninstall. + * Plugin management hooks: list, install, activate, deactivate, uninstall, upload, install-url. */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -65,3 +65,35 @@ export function useUninstallPlugin() { }, }); } + +export function useUploadPlugin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append('file', file); + const { default: apiClient } = await import('./client'); + const res = await apiClient.post('/api/v1/plugins/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['plugins'] }); + }, + }); +} + +export function useInstallPluginFromUrl() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (url: string) => { + const { default: apiClient } = await import('./client'); + const res = await apiClient.post('/api/v1/plugins/install-url', { url }); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['plugins'] }); + }, + }); +} \ No newline at end of file diff --git a/frontend/src/components/contacts/ContactDetail.tsx b/frontend/src/components/contacts/ContactDetail.tsx index 06b7294..ee91b20 100644 --- a/frontend/src/components/contacts/ContactDetail.tsx +++ b/frontend/src/components/contacts/ContactDetail.tsx @@ -8,7 +8,11 @@ import { Input } from '@/components/ui/Input'; import { useToast } from '@/components/ui/Toast'; import { Loader2 } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; import { HistoryViewer } from '@/components/HistoryViewer'; +import { usePluginStore } from '@/store/pluginStore'; +import { PluginPage } from '@/components/plugins/PluginLoader'; +import { useAuthStore } from '@/store/authStore'; import { type UnifiedContact, type ContactPerson, @@ -135,6 +139,11 @@ function ContactPersonModal({ ); } +function getIcon(name: string): React.ReactNode { + const Icon = (LucideIcons as any)[name]; + return Icon ? : ; +} + export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) { const { t } = useTranslation(); const toast = useToast(); @@ -144,6 +153,9 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe const deletePersonMutation = useDeleteContactPerson(); const [personModalOpen, setPersonModalOpen] = useState(false); const [editingPerson, setEditingPerson] = useState(null); + const [activeTab, setActiveTab] = useState('details'); + const pluginTabs = usePluginStore(s => s.getDetailTabsForEntity('contact')); + const user = useAuthStore(s => s.user); if (loading) { return ( @@ -203,6 +215,21 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe } }; + const visiblePluginTabs = pluginTabs.filter(tab => { + if (!tab.permission) return true; + return user?.permissions?.includes(tab.permission) ?? false; + }); + + const allTabs = [ + { id: 'details', label: t('contacts.details', 'Details'), icon: null }, + ...visiblePluginTabs.map(tab => ({ + id: tab.component, + label: t(tab.label_key, tab.label), + icon: tab.icon, + component: tab.component, + })), + ]; + return (
{/* Header */} @@ -224,7 +251,28 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
-
+ {/* Tab Bar */} +
+ {allTabs.map(tab => ( + + ))} +
+ + {activeTab === 'details' ? ( +
{/* Contact Persons */}
)}
+ ) : ( +
+ {allTabs.filter((t): t is { id: string; label: string; icon: string; component: string } => t.id !== 'details').map(tab => ( + activeTab === tab.id && ( + + ) + ))} +
+ )} +
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 0dfa5e6..da7244d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -4,6 +4,8 @@ import { NavLink, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useUIStore } from '@/store/uiStore'; import { Calendar, ChevronRight, FileText, Home, Mail, Monitor, Users } from 'lucide-react'; +import { usePluginStore } from '@/store/pluginStore'; +import * as LucideIcons from 'lucide-react'; interface NavLeaf { to: string; @@ -26,6 +28,11 @@ const chevronIcon = (expanded: boolean) => (
+ } + > + + + + ); +} diff --git a/frontend/src/components/plugins/PluginRegistry.tsx b/frontend/src/components/plugins/PluginRegistry.tsx new file mode 100644 index 0000000..f9ec2ae --- /dev/null +++ b/frontend/src/components/plugins/PluginRegistry.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { useActivePluginManifests } from '@/api/pluginManifests'; +import { usePluginStore } from '@/store/pluginStore'; + +/** + * PluginRegistry — invisible provider component that fetches active plugin + * UI manifests on mount and populates the plugin store. + * + * Must be placed inside AppShell or at the root of the protected route tree. + */ +export function PluginRegistry() { + const { data, isLoading, error } = useActivePluginManifests(); + const setManifests = usePluginStore((s) => s.setManifests); + const setLoading = usePluginStore((s) => s.setLoading); + const setError = usePluginStore((s) => s.setError); + + useEffect(() => { + setLoading(isLoading); + }, [isLoading, setLoading]); + + useEffect(() => { + if (data?.plugins) { + setManifests(data.plugins); + } + }, [data, setManifests]); + + useEffect(() => { + if (error) { + setError(error instanceof Error ? error.message : 'Failed to load plugin manifests'); + } + }, [error, setError]); + + // This component renders nothing visible + return null; +} diff --git a/frontend/src/components/plugins/PluginRouteRenderer.tsx b/frontend/src/components/plugins/PluginRouteRenderer.tsx new file mode 100644 index 0000000..ce144f3 --- /dev/null +++ b/frontend/src/components/plugins/PluginRouteRenderer.tsx @@ -0,0 +1,62 @@ +import { useLocation } from 'react-router-dom'; +import { usePluginStore } from '@/store/pluginStore'; +import { PluginPage } from './PluginLoader'; + +/** + * PluginRouteRenderer — catch-all route handler that checks the current URL + * against all plugin page_routes from the plugin store. + * + * If a matching route is found, it renders the plugin's page component. + * Otherwise it renders a simple "Not Found" message. + * + * This component is intended to be used as the last child of the protected + * route group in routes/index.tsx: + * { path: '*', element: } + */ +export function PluginRouteRenderer() { + const location = useLocation(); + const getAllPageRoutes = usePluginStore((s) => s.getAllPageRoutes); + const loaded = usePluginStore((s) => s.loaded); + + const routes = getAllPageRoutes(); + + // Find the first matching route (exact match or prefix match for nested routes) + const matchedRoute = routes.find((r) => { + // Exact match + if (location.pathname === r.path) return true; + // Prefix match for nested routes (e.g. /calendar/settings matches /calendar) + if (r.path !== '/' && location.pathname.startsWith(r.path + '/')) return true; + return false; + }); + + if (matchedRoute) { + // Find the plugin name for display + const manifests = usePluginStore.getState().manifests; + const plugin = manifests.find((m) => + m.page_routes.some((pr) => pr.path === matchedRoute.path) + ); + const pluginName = plugin?.display_name || plugin?.name || matchedRoute.path; + + return ( + + ); + } + + // If manifests haven't loaded yet, show nothing (the static router handles it) + if (!loaded) { + return null; + } + + // No plugin route matched — show a simple not-found + return ( +
+

Page Not Found

+

+ The page {location.pathname} was not found. +

+
+ ); +} diff --git a/frontend/src/components/plugins/__tests__/PluginLoader.test.tsx b/frontend/src/components/plugins/__tests__/PluginLoader.test.tsx new file mode 100644 index 0000000..49fc6f3 --- /dev/null +++ b/frontend/src/components/plugins/__tests__/PluginLoader.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +// We test the error boundary and component cache directly +// by importing the module internals +import { PluginPage } from '../PluginLoader'; + +// The PluginErrorBoundary is not exported, so we test through PluginPage +// The componentCache is module-level and persists across tests + +describe('PluginPage', () => { + it('shows error fallback when component fails to load', async () => { + // Use a non-existent component path - the dynamic import will fail + render(); + + // The error boundary should catch the import error and show fallback + const errorMsg = await screen.findByText(/Failed to load plugin/); + expect(errorMsg).toBeInTheDocument(); + expect(errorMsg).toHaveTextContent('broken'); + }); + + it('shows error fallback with correct plugin name', async () => { + render(); + + const errorMsg = await screen.findByText(/Failed to load plugin/); + expect(errorMsg).toHaveTextContent('my_custom_plugin'); + }); + + it('error boundary catches import errors and shows fallback UI', async () => { + render(); + + // The error boundary should catch the failed dynamic import + const alert = await screen.findByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent('Failed to load plugin: test'); + + // Verify the error message has the correct CSS classes + expect(alert.className).toContain('text-red-600'); + }); + + it('handles empty component path gracefully', async () => { + render(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Failed to load plugin: empty'); + }); + + it('error boundary has role="alert" for accessibility', async () => { + render(); + + const alert = await screen.findByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent('Failed to load plugin: test'); + }); + + it('handles different plugin names in error boundary', async () => { + render(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('plugin_x'); + }); +}); diff --git a/frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx b/frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx new file mode 100644 index 0000000..d13e39e --- /dev/null +++ b/frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { PluginRegistry } from '../PluginRegistry'; +import { usePluginStore } from '@/store/pluginStore'; +import type { PluginUiManifest } from '@/store/pluginStore'; + +// Mock the API hook +const mockManifests: PluginUiManifest[] = [ + { + name: 'test_plugin', + display_name: 'Test Plugin', + version: '1.0.0', + is_core: false, + menu_items: [ + { label_key: 'nav.test', label: 'Test', path: '/test', icon: 'TestTube', group: '', order: 100, badge_key: '' }, + ], + page_routes: [ + { path: '/test', component: '@/pages/Test', parent: '', protected: true, order: 100 }, + ], + detail_tabs: [], + settings_pages: [], + dashboard_widgets: [], + }, +]; + +let mockQueryResult: any = { + data: { plugins: mockManifests, total: 1 }, + isLoading: false, + error: null, +}; + +vi.mock('@/api/pluginManifests', () => ({ + useActivePluginManifests: () => mockQueryResult, +})); + +describe('PluginRegistry', () => { + beforeEach(() => { + usePluginStore.getState().reset(); + mockQueryResult = { + data: { plugins: mockManifests, total: 1 }, + isLoading: false, + error: null, + }; + }); + + it('renders nothing visible', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('populates store with manifests on mount', () => { + render(); + const manifests = usePluginStore.getState().manifests; + expect(manifests).toHaveLength(1); + expect(manifests[0].name).toBe('test_plugin'); + }); + + it('sets loading state while fetching', () => { + mockQueryResult = { data: null, isLoading: true, error: null }; + render(); + expect(usePluginStore.getState().loading).toBe(true); + }); + + it('sets error state on fetch failure', () => { + mockQueryResult = { data: null, isLoading: false, error: new Error('Network error') }; + render(); + expect(usePluginStore.getState().error).toBe('Network error'); + }); + + it('handles empty manifests gracefully', () => { + mockQueryResult = { data: { plugins: [], total: 0 }, isLoading: false, error: null }; + render(); + const manifests = usePluginStore.getState().manifests; + expect(manifests).toHaveLength(0); + expect(usePluginStore.getState().loaded).toBe(true); + }); +}); diff --git a/frontend/src/components/plugins/__tests__/PluginRouteRenderer.test.tsx b/frontend/src/components/plugins/__tests__/PluginRouteRenderer.test.tsx new file mode 100644 index 0000000..29b1989 --- /dev/null +++ b/frontend/src/components/plugins/__tests__/PluginRouteRenderer.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { PluginRouteRenderer } from '../PluginRouteRenderer'; +import { usePluginStore } from '@/store/pluginStore'; +import type { PluginUiManifest } from '@/store/pluginStore'; + +// Mock PluginPage to avoid lazy loading issues in tests +vi.mock('../PluginLoader', () => ({ + PluginPage: ({ component, pluginName }: { component: string; pluginName: string }) => ( +
+ Plugin: {pluginName} +
+ ), +})); + +const mockManifests: PluginUiManifest[] = [ + { + name: 'mail_plugin', + display_name: 'Mail', + version: '1.0.0', + is_core: true, + menu_items: [], + page_routes: [ + { path: '/mail', component: '@/pages/Mail', parent: '', protected: true, order: 100 }, + { path: '/mail/settings', component: '@/pages/MailSettings', parent: '/mail', protected: true, order: 200 }, + ], + detail_tabs: [], + settings_pages: [], + dashboard_widgets: [], + }, + { + name: 'calendar_plugin', + display_name: 'Calendar', + version: '1.0.0', + is_core: false, + menu_items: [], + page_routes: [ + { path: '/calendar', component: '@/pages/Calendar', parent: '', protected: true, order: 200 }, + ], + detail_tabs: [], + settings_pages: [], + dashboard_widgets: [], + }, +]; + +describe('PluginRouteRenderer', () => { + beforeEach(() => { + usePluginStore.getState().reset(); + }); + + it('renders plugin page for a matching route', () => { + usePluginStore.setState({ + manifests: mockManifests, + loaded: true, + }); + render( + + + + ); + expect(screen.getByTestId('plugin-page')).toBeInTheDocument(); + expect(screen.getByText('Plugin: Mail')).toBeInTheDocument(); + }); + + it('renders plugin page for a nested route', () => { + usePluginStore.setState({ + manifests: mockManifests, + loaded: true, + }); + render( + + + + ); + expect(screen.getByTestId('plugin-page')).toBeInTheDocument(); + expect(screen.getByText('Plugin: Mail')).toBeInTheDocument(); + }); + + it('shows Not Found for unmatched URLs', () => { + usePluginStore.setState({ + manifests: mockManifests, + loaded: true, + }); + render( + + + + ); + expect(screen.getByText('Page Not Found')).toBeInTheDocument(); + expect(screen.getByText(/unknown/)).toBeInTheDocument(); + }); + + it('renders nothing when manifests have not loaded yet', () => { + usePluginStore.setState({ + manifests: [], + loaded: false, + }); + const { container } = render( + + + + ); + expect(container.innerHTML).toBe(''); + }); + + it('matches the first route when multiple match', () => { + usePluginStore.setState({ + manifests: mockManifests, + loaded: true, + }); + render( + + + + ); + expect(screen.getByText('Plugin: Calendar')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7a6294a..921ce36 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { usePluginStore } from '@/store/pluginStore'; +import * as LucideIcons from 'lucide-react'; export function SettingsPage() { const { t } = useTranslation(); + const pluginSettingsPages = usePluginStore(s => s.getAllSettingsPages()); - const navItems = [ + const hardcodedNavItems = [ { to: '/settings/system', label: t('systemSettings.title'), icon: '\u2699\ufe0f' }, { to: '/settings/roles', label: t('settings.roles'), icon: '\ud83d\udd11' }, { to: '/settings/users', label: t('settings.users'), icon: '\ud83d\udc65' }, @@ -21,6 +24,20 @@ export function SettingsPage() { { to: '/settings/theme', label: t('settings.theme', 'Theme'), icon: '\ud83c\udfa8' }, ]; + const existingPaths = new Set(hardcodedNavItems.map(item => item.to)); + const pluginNavItems = pluginSettingsPages + .filter(p => !existingPaths.has(`/settings/${p.path}`)) + .map(p => ({ + to: `/settings/${p.path}`, + label: t(p.label_key, p.label), + icon: (() => { + const Icon = (LucideIcons as any)[p.icon]; + return Icon ? React.createElement(Icon, { className: 'w-4 h-4' }) : '\ud83d\udce6'; + })(), + })); + + const navItems = [...hardcodedNavItems, ...pluginNavItems]; + return (
+ {/* Install Plugin Section */} + + {plugins.length === 0 ? ( ) : ( @@ -252,4 +379,4 @@ export function SettingsPluginsPage() {
); -} +} \ No newline at end of file diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 69909a0..c278f72 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -6,6 +6,7 @@ import { LoginPage } from '@/pages/Login'; import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest'; import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm'; import { Loader2 } from 'lucide-react'; +import { PluginRouteRenderer } from '@/components/plugins/PluginRouteRenderer'; // Lazy-loaded pages (code-splitting) const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage }))); @@ -100,8 +101,10 @@ const router = createBrowserRouter([ { path: 'ai', element: withSuspense() }, { path: 'ai-proactive', element: withSuspense() }, { path: 'theme', element: withSuspense() }, + { path: '*', element: }, ], }, + { path: '*', element: }, ], }, ]); diff --git a/frontend/src/store/__tests__/pluginStore.test.ts b/frontend/src/store/__tests__/pluginStore.test.ts new file mode 100644 index 0000000..da7a412 --- /dev/null +++ b/frontend/src/store/__tests__/pluginStore.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { usePluginStore } from '../pluginStore'; +import type { PluginUiManifest } from '../pluginStore'; + +const mockManifests: PluginUiManifest[] = [ + { + name: 'plugin_a', + display_name: 'Plugin A', + version: '1.0.0', + is_core: false, + menu_items: [ + { label_key: 'nav.a', label: 'A', path: '/a', icon: 'A', group: '', order: 200, badge_key: '' }, + { label_key: 'nav.a2', label: 'A2', path: '/a2', icon: 'A', group: '', order: 50, badge_key: '' }, + ], + page_routes: [ + { path: '/a', component: '@/pages/A', parent: '', protected: true, order: 200 }, + ], + detail_tabs: [ + { entity_type: 'contact', label_key: 'tabs.a', label: 'Tab A', component: '@/components/TabA', icon: 'A', order: 100, permission: '' }, + { entity_type: 'company', label_key: 'tabs.a2', label: 'Tab A2', component: '@/components/TabA2', icon: 'A', order: 50, permission: '' }, + ], + settings_pages: [ + { path: 'a', label_key: 'settings.a', label: 'Settings A', component: '@/pages/SettingsA', icon: 'A', order: 200, permission: '' }, + ], + dashboard_widgets: [ + { id: 'widget_a', label_key: 'widgets.a', label: 'Widget A', component: '@/components/WidgetA', icon: 'A', order: 200, col_span: 2, row_span: 1, permission: '' }, + ], + }, + { + name: 'plugin_b', + display_name: 'Plugin B', + version: '1.0.0', + is_core: true, + menu_items: [ + { label_key: 'nav.b', label: 'B', path: '/b', icon: 'B', group: '', order: 100, badge_key: '' }, + ], + page_routes: [ + { path: '/b', component: '@/pages/B', parent: '', protected: true, order: 100 }, + ], + detail_tabs: [ + { entity_type: 'contact', label_key: 'tabs.b', label: 'Tab B', component: '@/components/TabB', icon: 'B', order: 200, permission: '' }, + ], + settings_pages: [ + { path: 'b', label_key: 'settings.b', label: 'Settings B', component: '@/pages/SettingsB', icon: 'B', order: 100, permission: '' }, + ], + dashboard_widgets: [ + { id: 'widget_b', label_key: 'widgets.b', label: 'Widget B', component: '@/components/WidgetB', icon: 'B', order: 100, col_span: 1, row_span: 1, permission: '' }, + ], + }, +]; + +describe('pluginStore', () => { + beforeEach(() => { + usePluginStore.getState().reset(); + }); + + describe('getAllMenuItems', () => { + it('returns empty array when no manifests', () => { + const items = usePluginStore.getState().getAllMenuItems(); + expect(items).toHaveLength(0); + }); + + it('merges menu items from all manifests', () => { + usePluginStore.setState({ manifests: mockManifests }); + const items = usePluginStore.getState().getAllMenuItems(); + expect(items).toHaveLength(3); + }); + + it('sorts menu items by order ascending', () => { + usePluginStore.setState({ manifests: mockManifests }); + const items = usePluginStore.getState().getAllMenuItems(); + expect(items[0].order).toBe(50); + expect(items[1].order).toBe(100); + expect(items[2].order).toBe(200); + }); + }); + + describe('getDetailTabsForEntity', () => { + it('returns empty array when no manifests', () => { + const tabs = usePluginStore.getState().getDetailTabsForEntity('contact'); + expect(tabs).toHaveLength(0); + }); + + it('filters tabs by entity_type', () => { + usePluginStore.setState({ manifests: mockManifests }); + const contactTabs = usePluginStore.getState().getDetailTabsForEntity('contact'); + expect(contactTabs).toHaveLength(2); + expect(contactTabs.every((t) => t.entity_type === 'contact')).toBe(true); + }); + + it('returns empty array for entity with no tabs', () => { + usePluginStore.setState({ manifests: mockManifests }); + const taskTabs = usePluginStore.getState().getDetailTabsForEntity('task'); + expect(taskTabs).toHaveLength(0); + }); + + it('sorts tabs by order ascending', () => { + usePluginStore.setState({ manifests: mockManifests }); + const contactTabs = usePluginStore.getState().getDetailTabsForEntity('contact'); + expect(contactTabs[0].order).toBe(100); + expect(contactTabs[1].order).toBe(200); + }); + }); + + describe('getAllSettingsPages', () => { + it('returns empty array when no manifests', () => { + const pages = usePluginStore.getState().getAllSettingsPages(); + expect(pages).toHaveLength(0); + }); + + it('merges settings pages from all manifests', () => { + usePluginStore.setState({ manifests: mockManifests }); + const pages = usePluginStore.getState().getAllSettingsPages(); + expect(pages).toHaveLength(2); + }); + + it('sorts settings pages by order ascending', () => { + usePluginStore.setState({ manifests: mockManifests }); + const pages = usePluginStore.getState().getAllSettingsPages(); + expect(pages[0].order).toBe(100); + expect(pages[1].order).toBe(200); + }); + }); + + describe('getAllDashboardWidgets', () => { + it('returns empty array when no manifests', () => { + const widgets = usePluginStore.getState().getAllDashboardWidgets(); + expect(widgets).toHaveLength(0); + }); + + it('merges widgets from all manifests', () => { + usePluginStore.setState({ manifests: mockManifests }); + const widgets = usePluginStore.getState().getAllDashboardWidgets(); + expect(widgets).toHaveLength(2); + }); + + it('sorts widgets by order ascending', () => { + usePluginStore.setState({ manifests: mockManifests }); + const widgets = usePluginStore.getState().getAllDashboardWidgets(); + expect(widgets[0].order).toBe(100); + expect(widgets[1].order).toBe(200); + }); + }); + + describe('reset', () => { + it('clears all state', () => { + usePluginStore.setState({ manifests: mockManifests, loading: true, error: 'test', loaded: true }); + usePluginStore.getState().reset(); + const state = usePluginStore.getState(); + expect(state.manifests).toHaveLength(0); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + expect(state.loaded).toBe(false); + }); + }); + + describe('setManifests', () => { + it('sets manifests and marks as loaded', () => { + usePluginStore.getState().setManifests(mockManifests); + const state = usePluginStore.getState(); + expect(state.manifests).toHaveLength(2); + expect(state.loaded).toBe(true); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + }); + }); + + describe('getAllPageRoutes', () => { + it('returns empty array when no manifests', () => { + const routes = usePluginStore.getState().getAllPageRoutes(); + expect(routes).toHaveLength(0); + }); + + it('merges page routes from all manifests', () => { + usePluginStore.setState({ manifests: mockManifests }); + const routes = usePluginStore.getState().getAllPageRoutes(); + expect(routes).toHaveLength(2); + }); + + it('sorts page routes by order ascending', () => { + usePluginStore.setState({ manifests: mockManifests }); + const routes = usePluginStore.getState().getAllPageRoutes(); + expect(routes[0].order).toBe(100); + expect(routes[1].order).toBe(200); + }); + }); +}); diff --git a/frontend/src/store/pluginStore.ts b/frontend/src/store/pluginStore.ts new file mode 100644 index 0000000..3791330 --- /dev/null +++ b/frontend/src/store/pluginStore.ts @@ -0,0 +1,128 @@ +import { create } from 'zustand'; + +export interface PluginMenuItem { + label_key: string; + label: string; + path: string; + icon: string; + group: string; + order: number; + badge_key: string; +} + +export interface PluginPageRoute { + path: string; + component: string; + parent: string; + protected: boolean; + order: number; +} + +export interface PluginDetailTab { + entity_type: string; + label_key: string; + label: string; + component: string; + icon: string; + order: number; + permission: string; +} + +export interface PluginSettingsPage { + path: string; + label_key: string; + label: string; + component: string; + icon: string; + order: number; + permission: string; +} + +export interface PluginDashboardWidget { + id: string; + label_key: string; + label: string; + component: string; + icon: string; + order: number; + col_span: number; + row_span: number; + permission: string; +} + +export interface PluginUiManifest { + name: string; + display_name: string; + version: string; + is_core: boolean; + menu_items: PluginMenuItem[]; + page_routes: PluginPageRoute[]; + detail_tabs: PluginDetailTab[]; + settings_pages: PluginSettingsPage[]; + dashboard_widgets: PluginDashboardWidget[]; +} + +interface PluginState { + manifests: PluginUiManifest[]; + loading: boolean; + error: string | null; + loaded: boolean; + setManifests: (manifests: PluginUiManifest[]) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + reset: () => void; + // Computed selectors + getAllMenuItems: () => PluginMenuItem[]; + getAllPageRoutes: () => PluginPageRoute[]; + getDetailTabsForEntity: (entityType: string) => PluginDetailTab[]; + getAllSettingsPages: () => PluginSettingsPage[]; + getAllDashboardWidgets: () => PluginDashboardWidget[]; +} + +export const usePluginStore = create((set, get) => ({ + manifests: [], + loading: false, + error: null, + loaded: false, + + setManifests: (manifests) => set({ manifests, loaded: true, loading: false, error: null }), + setLoading: (loading) => set({ loading }), + setError: (error) => set({ error, loading: false }), + reset: () => set({ manifests: [], loading: false, error: null, loaded: false }), + + getAllMenuItems: () => { + const { manifests } = get(); + return manifests + .flatMap((m) => m.menu_items) + .sort((a, b) => a.order - b.order); + }, + + getAllPageRoutes: () => { + const { manifests } = get(); + return manifests + .flatMap((m) => m.page_routes) + .sort((a, b) => a.order - b.order); + }, + + getDetailTabsForEntity: (entityType: string) => { + const { manifests } = get(); + return manifests + .flatMap((m) => m.detail_tabs) + .filter((t) => t.entity_type === entityType) + .sort((a, b) => a.order - b.order); + }, + + getAllSettingsPages: () => { + const { manifests } = get(); + return manifests + .flatMap((m) => m.settings_pages) + .sort((a, b) => a.order - b.order); + }, + + getAllDashboardWidgets: () => { + const { manifests } = get(); + return manifests + .flatMap((m) => m.dashboard_widgets) + .sort((a, b) => a.order - b.order); + }, +})); diff --git a/templates/plugin-template/README.md b/templates/plugin-template/README.md new file mode 100644 index 0000000..7530578 --- /dev/null +++ b/templates/plugin-template/README.md @@ -0,0 +1,60 @@ +# Plugin Template + +A minimal plugin template for LeoCRM. Copy this directory to `app/plugins/builtins//` and customize. + +## Quick Start + +1. **Copy the template:** + ```bash + cp -r templates/plugin-template app/plugins/builtins/my_plugin + ``` + +2. **Rename the plugin class:** + Edit `plugin.py` and rename `ExamplePlugin` to your plugin name. + +3. **Update the manifest:** + - Change `name`, `version`, `display_name`, `description` + - Add your routes, events, migrations, permissions + - Add UI contributions (menu items, page routes, etc.) + +4. **Implement routes:** + Edit `routes.py` with your API endpoints. + +5. **Add database models:** + Uncomment and customize `models.py`. + +6. **Write migrations:** + Add SQL migration files to `migrations/`. + +7. **Write tests:** + Add tests to `tests/test_plugin.py`. + +## Directory Structure + +``` +templates/plugin-template/ +├── __init__.py # Package init +├── plugin.py # Plugin class with manifest (required) +├── routes.py # FastAPI route definitions +├── models.py # SQLAlchemy models (optional, commented out) +├── schemas.py # Pydantic schemas (optional) +├── services.py # Business logic (optional) +├── migrations/ # SQL migration files +│ └── 0001_initial.sql +├── tests/ # Plugin tests +│ ├── __init__.py +│ └── test_plugin.py +└── README.md # This file +``` + +## Manifest Fields + +See `docs/plugin-development-guide.md` for a complete reference of all manifest fields. + +## Key Points + +- All API routes must be secured with `require_permission` +- Event handlers are named `on_` with dots replaced by underscores +- Migration files run in alphanumeric order +- UI component paths use the `@/` alias (resolved to `src/` by Vite) +- i18n keys should be prefixed with the plugin name diff --git a/templates/plugin-template/__init__.py b/templates/plugin-template/__init__.py new file mode 100644 index 0000000..9765232 --- /dev/null +++ b/templates/plugin-template/__init__.py @@ -0,0 +1 @@ +"""Plugin template package.""" diff --git a/templates/plugin-template/migrations/0001_initial.sql b/templates/plugin-template/migrations/0001_initial.sql new file mode 100644 index 0000000..06bf433 --- /dev/null +++ b/templates/plugin-template/migrations/0001_initial.sql @@ -0,0 +1,17 @@ +-- 0001_initial.sql +-- Initial migration for the example plugin. +-- Creates the example_items table. + +CREATE TABLE IF NOT EXISTS example_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + description TEXT, + config JSONB DEFAULT '{}', + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_example_items_tenant_id ON example_items(tenant_id); +CREATE INDEX IF NOT EXISTS idx_example_items_name ON example_items(name); diff --git a/templates/plugin-template/models.py b/templates/plugin-template/models.py new file mode 100644 index 0000000..d62a1cb --- /dev/null +++ b/templates/plugin-template/models.py @@ -0,0 +1,38 @@ +""" +SQLAlchemy models for the example plugin. + +Uncomment and customize for your plugin's data model. +""" + +# from __future__ import annotations +# +# import uuid +# from datetime import datetime +# +# from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text +# from sqlalchemy.dialects.postgresql import UUID +# from sqlalchemy.orm import Mapped, mapped_column, relationship +# +# from app.database import Base +# +# +# class ExampleItem(Base): +# __tablename__ = "example_items" +# +# id: Mapped[uuid.UUID] = mapped_column( +# UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 +# ) +# tenant_id: Mapped[uuid.UUID] = mapped_column( +# UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False +# ) +# name: Mapped[str] = mapped_column(String(200), nullable=False) +# description: Mapped[str | None] = mapped_column(Text, nullable=True) +# is_active: Mapped[bool] = mapped_column(Boolean, default=True) +# created_at: Mapped[datetime] = mapped_column( +# DateTime(timezone=True), default=datetime.utcnow +# ) +# updated_at: Mapped[datetime] = mapped_column( +# DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow +# ) +# +# tenant = relationship("Tenant", back_populates="example_items") diff --git a/templates/plugin-template/plugin.py b/templates/plugin-template/plugin.py new file mode 100644 index 0000000..c64928e --- /dev/null +++ b/templates/plugin-template/plugin.py @@ -0,0 +1,178 @@ +""" +Plugin Template — Example plugin demonstrating all manifest fields. + +Copy this directory to app/plugins/builtins// and customize. +""" + +from __future__ import annotations + +from app.plugins.base import BasePlugin +from app.plugins.manifest import ( + PluginManifest, + PluginRouteDef, + FieldDefinition, + FrontendMenuItem, + FrontendPageRoute, + FrontendDetailTab, + FrontendSettingsPage, + FrontendDashboardWidget, +) + + +class ExamplePlugin(BasePlugin): + """ + Example plugin demonstrating all manifest fields. + + Remove or comment out fields you don't need. + """ + + manifest = PluginManifest( + # ── Core Metadata ────────────────────────────────────────────── + name="example_plugin", + version="1.0.0", + display_name="Example Plugin", + description="A minimal example plugin demonstrating all manifest fields.", + dependencies=[], + is_core=False, + # ── API Routes ───────────────────────────────────────────────── + routes=[ + PluginRouteDef( + path="/api/v1/example", + module="app.plugins.builtins.example.routes", + router_attr="router", + ), + ], + # ── Event Subscriptions ──────────────────────────────────────── + events=[ + "contact.created", + "contact.updated", + ], + # ── Database Migrations ──────────────────────────────────────── + migrations=[ + "0001_initial.sql", + ], + # ── RBAC Permissions ─────────────────────────────────────────── + permissions=[ + "example:read", + "example:write", + ], + # ── Field Definitions (Field-Level Permissions) ──────────────── + field_definitions=[ + FieldDefinition( + module="contacts", + field="custom_field", + label="Custom Field", + sensitivity="normal", + ), + ], + # ── AI Agent Capabilities ────────────────────────────────────── + agent_capabilities=[ + "example:search", + ], + # ── Frontend UI: Sidebar Menu Items ──────────────────────────── + menu_items=[ + FrontendMenuItem( + label_key="nav.example", + label="Example", + path="/example", + icon="Sparkles", + order=100, + ), + ], + # ── Frontend UI: Page Routes ─────────────────────────────────── + page_routes=[ + FrontendPageRoute( + path="/example", + component="@/pages/Example", + protected=True, + ), + ], + # ── Frontend UI: Detail Tabs ─────────────────────────────────── + detail_tabs=[ + FrontendDetailTab( + entity_type="contact", + label_key="tabs.example", + label="Example", + component="@/components/ExampleTab", + icon="Sparkles", + order=50, + permission="example:read", + ), + ], + # ── Frontend UI: Settings Pages ──────────────────────────────── + settings_pages=[ + FrontendSettingsPage( + path="example", + label_key="settings.example", + label="Example", + component="@/pages/ExampleSettings", + icon="Sparkles", + order=100, + permission="example:write", + ), + ], + # ── Frontend UI: Dashboard Widgets ───────────────────────────── + dashboard_widgets=[ + FrontendDashboardWidget( + id="example_stats", + label_key="widgets.example", + label="Example Stats", + component="@/components/ExampleWidget", + icon="LayoutDashboard", + order=100, + col_span=2, + row_span=1, + permission="example:read", + ), + ], + ) + + # ─── Lifecycle Hooks ─────────────────────────────────────────────── + + async def on_install(self, db, service_container): + """Called after migrations are run.""" + # Perform seed data or initial setup here + pass + + async def on_activate(self, db, service_container, event_bus): + """Called when the plugin is activated.""" + # Default implementation subscribes to manifest events + await super().on_activate(db, service_container, event_bus) + + async def on_deactivate(self, db, service_container, event_bus): + """Called when the plugin is deactivated.""" + # Default implementation unsubscribes all event listeners + await super().on_deactivate(db, service_container, event_bus) + + async def on_uninstall(self, db, service_container): + """Called before data tables are dropped.""" + # Clean up external resources here + pass + + # ─── Event Handlers ──────────────────────────────────────────────── + + async def on_contact_created(self, event_data: dict) -> None: + """Handle contact.created event.""" + contact_id = event_data.get("contact_id") + # React to new contact + pass + + async def on_contact_updated(self, event_data: dict) -> None: + """Handle contact.updated event.""" + contact_id = event_data.get("contact_id") + # React to contact update + pass + + # ─── Notification Types ──────────────────────────────────────────── + + def get_notification_types(self) -> list[dict]: + """Return notification types this plugin registers.""" + return [ + { + "type_key": "example.notification", + "label": "Example Notification", + "category": "general", + "description": "Notification from the example plugin", + "is_enabled_by_default": True, + }, + ] diff --git a/templates/plugin-template/routes.py b/templates/plugin-template/routes.py new file mode 100644 index 0000000..9000235 --- /dev/null +++ b/templates/plugin-template/routes.py @@ -0,0 +1,48 @@ +""" +API routes for the example plugin. + +Each route must be secured with require_permission. +""" + +from fastapi import APIRouter, Depends + +from app.deps import get_current_user, require_permission + +router = APIRouter() + + +@router.get( + "", + dependencies=[Depends(require_permission("example:read"))], +) +async def list_items(current_user: dict = Depends(get_current_user)): + """List all items.""" + return {"items": [], "total": 0} + + +@router.post( + "", + status_code=201, + dependencies=[Depends(require_permission("example:write"))], +) +async def create_item(current_user: dict = Depends(get_current_user)): + """Create a new item.""" + return {"status": "created"} + + +@router.get( + "/{item_id}", + dependencies=[Depends(require_permission("example:read"))], +) +async def get_item(item_id: str, current_user: dict = Depends(get_current_user)): + """Get a single item by ID.""" + return {"id": item_id} + + +@router.delete( + "/{item_id}", + dependencies=[Depends(require_permission("example:write"))], +) +async def delete_item(item_id: str, current_user: dict = Depends(get_current_user)): + """Delete an item by ID.""" + return {"status": "deleted"} diff --git a/templates/plugin-template/schemas.py b/templates/plugin-template/schemas.py new file mode 100644 index 0000000..adde78a --- /dev/null +++ b/templates/plugin-template/schemas.py @@ -0,0 +1,50 @@ +""" +Pydantic schemas for the example plugin. + +Customize these for your plugin's API request/response models. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + + +class ExampleItemBase(BaseModel): + """Base schema for an example item.""" + + name: str = Field(..., min_length=1, max_length=200, description="Item name") + description: str | None = Field(None, max_length=1000, description="Item description") + + +class ExampleItemCreate(ExampleItemBase): + """Schema for creating an example item.""" + pass + + +class ExampleItemUpdate(BaseModel): + """Schema for updating an example item.""" + + name: str | None = Field(None, min_length=1, max_length=200) + description: str | None = Field(None, max_length=1000) + + +class ExampleItemResponse(ExampleItemBase): + """Schema for returning an example item.""" + + id: UUID + tenant_id: UUID + is_active: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ExampleItemListResponse(BaseModel): + """Schema for a paginated list of example items.""" + + items: list[ExampleItemResponse] + total: int diff --git a/templates/plugin-template/services.py b/templates/plugin-template/services.py new file mode 100644 index 0000000..22c2132 --- /dev/null +++ b/templates/plugin-template/services.py @@ -0,0 +1,39 @@ +""" +Business logic services for the example plugin. + +Customize these for your plugin's business logic. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +class ExampleService: + """Service class for example plugin business logic.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def list_items(self, tenant_id: str, skip: int = 0, limit: int = 100) -> dict: + """List items for a tenant.""" + # TODO: Implement with actual database queries + return {"items": [], "total": 0} + + async def create_item(self, tenant_id: str, data: dict) -> dict: + """Create a new item.""" + # TODO: Implement with actual database operations + return {"id": "new-uuid", **data} + + async def get_item(self, item_id: str, tenant_id: str) -> dict | None: + """Get a single item by ID.""" + # TODO: Implement with actual database queries + return None + + async def delete_item(self, item_id: str, tenant_id: str) -> bool: + """Delete an item by ID.""" + # TODO: Implement with actual database operations + return True diff --git a/templates/plugin-template/tests/__init__.py b/templates/plugin-template/tests/__init__.py new file mode 100644 index 0000000..25f2c51 --- /dev/null +++ b/templates/plugin-template/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the example plugin.""" diff --git a/templates/plugin-template/tests/test_plugin.py b/templates/plugin-template/tests/test_plugin.py new file mode 100644 index 0000000..a754a51 --- /dev/null +++ b/templates/plugin-template/tests/test_plugin.py @@ -0,0 +1,63 @@ +"""Tests for the example plugin.""" + +import pytest +from httpx import AsyncClient, ASGITransport + +from app.main import create_app + + +@pytest.fixture +async def client(): + """Create a test client.""" + app = create_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +@pytest.fixture +def auth_headers(): + """Return headers with valid authentication.""" + return { + "Authorization": "Bearer test-token", + "X-Tenant-ID": "test-tenant", + } + + +@pytest.mark.asyncio +async def test_list_items_requires_auth(client: AsyncClient): + """Test that listing items requires authentication.""" + response = await client.get("/api/v1/example") + assert response.status_code in (401, 403) + + +@pytest.mark.asyncio +async def test_list_items_with_auth(client: AsyncClient, auth_headers: dict): + """Test that listing items works with authentication.""" + response = await client.get("/api/v1/example", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert "total" in data + + +@pytest.mark.asyncio +async def test_create_item_requires_write_permission(client: AsyncClient, auth_headers: dict): + """Test that creating items requires write permission.""" + response = await client.post( + "/api/v1/example", + headers=auth_headers, + json={"name": "Test Item"}, + ) + # May return 201 or 403 depending on test permissions + assert response.status_code in (201, 403) + + +@pytest.mark.asyncio +async def test_get_item_returns_404_for_missing(client: AsyncClient, auth_headers: dict): + """Test that getting a non-existent item returns 404.""" + response = await client.get( + "/api/v1/example/non-existent-id", + headers=auth_headers, + ) + assert response.status_code == 404