Phase 3: Plugin-UI-System (WordPress-Style)

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)
This commit is contained in:
Agent Zero
2026-07-23 19:01:18 +02:00
parent 4f70c1d912
commit fc96a2f86c
43 changed files with 3005 additions and 204 deletions
+10 -1
View File
@@ -6,7 +6,7 @@ import logging
from typing import Any from typing import Any
from app.plugins.base import BasePlugin 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__) logger = logging.getLogger(__name__)
@@ -40,6 +40,15 @@ class AIAssistantPlugin(BasePlugin):
"ai:tools", "ai:tools",
], ],
is_core=True, 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: def __init__(self) -> None:
+4 -1
View File
@@ -11,7 +11,7 @@ import logging
from typing import Any from typing import Any
from app.plugins.base import BasePlugin 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__) logger = logging.getLogger(__name__)
@@ -43,6 +43,9 @@ class AIProactivePlugin(BasePlugin):
"ai_proactive:config", "ai_proactive:config",
], ],
is_core=False, 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: def __init__(self) -> None:
+12 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from app.plugins.base import BasePlugin 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): class CalendarPlugin(BasePlugin):
@@ -41,4 +41,15 @@ class CalendarPlugin(BasePlugin):
"calendar:share", "calendar:share",
"calendar:admin", "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'),
],
) )
+12 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from app.plugins.base import BasePlugin 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): class DmsPlugin(BasePlugin):
@@ -31,4 +31,15 @@ class DmsPlugin(BasePlugin):
"dms:share", "dms:share",
"dms:admin", "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'),
],
) )
+4 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendDetailTab
class EntityLinksPlugin(BasePlugin): class EntityLinksPlugin(BasePlugin):
@@ -37,6 +37,9 @@ class EntityLinksPlugin(BasePlugin):
"entity_links:delete", "entity_links:delete",
], ],
is_core=True, 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: async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
+7 -1
View File
@@ -6,7 +6,7 @@ import logging
from typing import Any from typing import Any
from app.plugins.base import BasePlugin 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__) logger = logging.getLogger(__name__)
@@ -49,6 +49,12 @@ class KommunikationPlugin(BasePlugin):
"comm:delete", "comm:delete",
], ],
is_core=True, 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: async def on_activate(self, db, service_container, event_bus) -> None:
+15 -1
View File
@@ -7,7 +7,7 @@ import logging
from typing import Any from typing import Any
from app.plugins.base import BasePlugin 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__) logger = logging.getLogger(__name__)
@@ -56,6 +56,20 @@ class MailPlugin(BasePlugin):
events=[], 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"], 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"], 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( async def on_activate(
+6 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendSettingsPage
class PermissionsPlugin(BasePlugin): class PermissionsPlugin(BasePlugin):
@@ -31,4 +31,9 @@ class PermissionsPlugin(BasePlugin):
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=[],
is_core=True, 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'),
],
) )
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from app.plugins.base import BasePlugin 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): class ReportGeneratorPlugin(BasePlugin):
@@ -26,4 +26,10 @@ class ReportGeneratorPlugin(BasePlugin):
events=["report.requested", "report.generated"], events=["report.requested", "report.generated"],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=["reports.read", "reports.generate", "reports.manage_templates"], 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),
],
) )
+4 -1
View File
@@ -6,7 +6,7 @@ import logging
from typing import Any from typing import Any
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest from app.plugins.manifest import PluginManifest, FrontendSettingsPage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,6 +38,9 @@ class SystemNotifPlugin(BasePlugin):
migrations=[], migrations=[],
permissions=["system_notif:read"], permissions=["system_notif:read"],
is_core=False, 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: def __init__(self) -> None:
+4 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef from app.plugins.manifest import PluginManifest, PluginRouteDef, FrontendDetailTab
class TagsPlugin(BasePlugin): class TagsPlugin(BasePlugin):
@@ -31,4 +31,7 @@ class TagsPlugin(BasePlugin):
"tags:admin", "tags:admin",
], ],
is_core=True, 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'),
],
) )
@@ -6,7 +6,7 @@ import logging
from typing import Any from typing import Any
from app.plugins.base import BasePlugin 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__) logger = logging.getLogger(__name__)
@@ -40,6 +40,9 @@ class UnifiedSearchPlugin(BasePlugin):
migrations=["0001_initial.sql", "0002_embeddings.sql"], migrations=["0001_initial.sql", "0002_embeddings.sql"],
permissions=["search:read", "search:admin"], permissions=["search:read", "search:admin"],
is_core=False, is_core=False,
page_routes=[
FrontendPageRoute(path='/search', component='@/pages/GlobalSearchResults', protected=True),
],
) )
async def on_activate(self, db, service_container, event_bus) -> None: async def on_activate(self, db, service_container, event_bus) -> None:
+125
View File
@@ -24,6 +24,74 @@ class FieldDefinition(BaseModel):
sensitivity: str = Field(default="normal", description="normal|sensitive|critical") 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): class PluginManifest(BaseModel):
"""Manifest describing a plugin's metadata, dependencies, and capabilities.""" """Manifest describing a plugin's metadata, dependencies, and capabilities."""
@@ -58,6 +126,22 @@ class PluginManifest(BaseModel):
default_factory=list, default_factory=list,
description="AI agent capabilities this plugin provides (e.g. 'contact_search', 'email_draft')", 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") @field_validator("name")
@classmethod @classmethod
@@ -120,6 +204,31 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
"required": "false", "required": "false",
"description": "Required permissions", "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( example=PluginManifest(
name="example_plugin", name="example_plugin",
@@ -131,5 +240,21 @@ MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
events=["contact.created"], events=["contact.created"],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=["contacts.read"], 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,
)
],
), ),
) )
+31
View File
@@ -671,6 +671,37 @@ class PluginRegistry:
return plugins_list 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 ── # ── Internal Helpers ──
async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None: async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None:
+448 -4
View File
@@ -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 __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 pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import require_permission 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.plugins.migration_runner import MigrationValidationError
from app.services.plugin_service import get_plugin_service from app.services.plugin_service import get_plugin_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"]) router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"])
@@ -19,6 +34,14 @@ class PluginConfigUpdate(BaseModel):
config: dict 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("") @router.get("")
async def list_plugins( async def list_plugins(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -39,6 +62,23 @@ async def get_manifest_schema(
return service.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") @router.get("/{name}/config")
async def get_plugin_config( async def get_plugin_config(
name: str, name: str,
@@ -190,9 +230,9 @@ async def uninstall_plugin(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("plugins:configure")), 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 import uuid as uuid_mod
@@ -212,3 +252,407 @@ async def uninstall_plugin(
404, detail={"detail": str(exc), "code": "plugin_not_found"} 404, detail={"detail": str(exc), "code": "plugin_not_found"}
) from None ) from None
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"}) 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
+4
View File
@@ -261,6 +261,10 @@ class PluginService:
return MANIFEST_SCHEMA_DOC.model_dump() 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 # Global service instance
_service: PluginService | None = None _service: PluginService | None = None
+687 -182
View File
@@ -1,43 +1,296 @@
# LeoCRM Plugin Development Guide # LeoCRM Plugin Development Guide
> **Version:** 1.0 > **Version:** 2.0
> **Datum:** 2026-07-23 > **Date:** 2026-07-23
> **Gültig für:** Alle Plugin-Entwickler > **Applies to:** All plugin developers
--- ---
## 1. Plugin-Struktur ## 1. Overview
Jedes Plugin liegt unter `app/plugins/builtins/<plugin_name>/`: 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/<plugin_name>/`:
``` ```
app/plugins/builtins/my_plugin/ app/plugins/builtins/my_plugin/
├── __init__.py ├── __init__.py # Package init (can be empty)
├── plugin.py # Plugin-Klasse mit Manifest ├── plugin.py # Plugin class with manifest (required)
├── routes.py # API-Routes ├── routes.py # FastAPI route definitions
├── models.py # SQLAlchemy-Modelle (optional) ├── models.py # SQLAlchemy models (optional)
├── schemas.py # Pydantic-Schemas (optional) ├── schemas.py # Pydantic schemas (optional)
├── services.py # Business-Logik (optional) ├── services.py # Business logic (optional)
├── migrations/ # SQL-Migrationen ├── migrations/ # SQL migration files
│ └── 0001_initial.sql │ └── 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: `<entity>.<action>` (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: `<plugin_name>:<action>`.
### 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 ```python
from app.plugins.base import BasePlugin 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): class MyPlugin(BasePlugin):
manifest = PluginManifest( manifest = PluginManifest(
name="my_plugin", name="my_plugin",
version="1.0.0", version="1.0.0",
display_name="My Plugin", display_name="My Plugin",
description="Description of what the plugin does.", description="A comprehensive example plugin.",
dependencies=["permissions"], # Other plugins this depends on dependencies=["permissions"],
is_core=False,
routes=[ routes=[
PluginRouteDef( PluginRouteDef(
path="/api/v1/my-plugin", path="/api/v1/my-plugin",
@@ -47,40 +300,280 @@ class MyPlugin(BasePlugin):
], ],
events=["contact.created", "contact.updated"], events=["contact.created", "contact.updated"],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[ permissions=["my_plugin:read", "my_plugin:write", "my_plugin:admin"],
"my_plugin:read", field_definitions=[
"my_plugin:write", FieldDefinition(
"my_plugin:delete", module="contacts",
field="custom_field",
label="Custom Field",
sensitivity="normal",
),
], ],
agent_capabilities=[ agent_capabilities=["my_plugin:search"],
"my_plugin:search", menu_items=[
"my_plugin:analyze", 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 | ## 4. Plugin Lifecycle
|---|---|---|---|
| `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) |
## 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_<event_name>` 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: `<entity>.<action>`
- 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 ```python
permissions=[ permissions=[
@@ -88,12 +581,12 @@ permissions=[
"my_plugin:write", "my_plugin:write",
"my_plugin:delete", "my_plugin:delete",
"my_plugin:admin", "my_plugin:admin",
], ]
``` ```
### Routes absichern ### 9.2 Securing Routes
Jede Route muss mit `require_permission` abgesichert werden: Use the `require_permission` dependency:
```python ```python
from app.deps import get_current_user, require_permission 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"))]) @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)): 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: `<plugin_name>:<action>` - Format: `<plugin_name>:<action>`
- Standard-Actions: `read`, `write`, `delete`, `share`, `admin` - Standard actions: `read`, `write`, `delete`, `share`, `admin`
- Beispiele: `calendar:read`, `dms:write`, `tags:delete` - 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**. Field definitions in the manifest enable field-level access control:
### 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()`:
```python ```python
import litellm field_definitions=[
FieldDefinition(
response = await litellm.acompletion( module="companies",
model="openai/gpt-4o", # oder anthropic/claude-3-sonnet, ollama/llama3 field="annual_revenue",
messages=[ label="Annual Revenue",
{"role": "system", "content": system_prompt}, sensitivity="sensitive", # normal|sensitive|critical
{"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
``` ```
### Konfiguration ---
| Env-Var | Beschreibung | Standard | ## 10. AI Agent Integration
|---|---|---|
| `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` |
Wenn `AI_MODEL` und `AI_API_KEY` nicht gesetzt sind, läuft der LLM-Client im Mock-Modus ### 10.1 Agent Capabilities
(keyword-basierte Action-Mapping für Tests).
### 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. ```python
Jedes Tool deklariert Name, Beschreibung, JSON-Schema für Parameter und einen async Handler. agent_capabilities=[
"contact_search",
"email_draft",
"calendar_scheduling",
]
```
### 10.2 Tool Registry
Plugins can register tools for the AI assistant:
```python ```python
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
@@ -196,94 +665,31 @@ registry.register(
) )
``` ```
### Tool Handler ### 10.3 Tool Handler
Der Handler ist eine async Funktion, die Argumente und Kontext empfängt:
```python ```python
async def my_search_handler(arguments: dict, context: dict) -> str: async def my_search_handler(arguments: dict, context: dict) -> str:
query = arguments.get("query", "") query = arguments.get("query", "")
limit = arguments.get("limit", 10) limit = arguments.get("limit", 10)
# ... perform search ... # Perform search...
return json.dumps({"results": results}) return json.dumps({"results": results})
``` ```
### Tools bei Plugin-Deaktivierung abmelden ### 10.4 Cleanup on Deactivation
```python ```python
def on_deactivate(self): async def on_deactivate(self, db, service_container, event_bus):
registry = get_tool_registry() registry = get_tool_registry()
registry.unregister_plugin("my_plugin") 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 ### 11.1 Backend Tests
agent_capabilities=[
"contact_search", # Kontakt-Suche
"email_draft", # E-Mail-Entwürfe generieren
"calendar_scheduling", # Terminvorschläge
],
```
Diese Informationen werden vom AI Assistant verwendet, um Nutzern zu zeigen, Tests live in the plugin's `tests/` directory or in the central `tests/` folder:
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:
```python ```python
# tests/test_my_plugin.py # 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 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(<PluginRegistry />);
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 `<entity>.<action>`
- **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 ```python
# app/plugins/builtins/my_plugin/plugin.py # app/plugins/builtins/minimal_example/plugin.py
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef from app.plugins.manifest import PluginManifest, PluginRouteDef
class MyPlugin(BasePlugin): class MinimalExamplePlugin(BasePlugin):
manifest = PluginManifest( manifest = PluginManifest(
name="my_plugin", name="minimal_example",
version="1.0.0", version="1.0.0",
display_name="My Plugin", display_name="Minimal Example",
description="A minimal example plugin.", description="A minimal example plugin.",
dependencies=[], dependencies=[],
routes=[ routes=[
PluginRouteDef( PluginRouteDef(
path="/api/v1/my-plugin", path="/api/v1/minimal-example",
module="app.plugins.builtins.my_plugin.routes", module="app.plugins.builtins.minimal_example.routes",
router_attr="router", router_attr="router",
), ),
], ],
events=[], events=[],
migrations=[], migrations=[],
permissions=["my_plugin:read", "my_plugin:write"], permissions=["minimal_example:read"],
agent_capabilities=[],
) )
``` ```
```python ```python
# app/plugins/builtins/my_plugin/routes.py # app/plugins/builtins/minimal_example/routes.py
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
router = APIRouter() 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)): async def list_items(current_user: dict = Depends(get_current_user)):
return {"items": []} 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.*
+15
View File
@@ -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,
});
}
+33 -1
View File
@@ -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'; 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'] });
},
});
}
@@ -8,7 +8,11 @@ import { Input } from '@/components/ui/Input';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { HistoryViewer } from '@/components/HistoryViewer'; import { HistoryViewer } from '@/components/HistoryViewer';
import { usePluginStore } from '@/store/pluginStore';
import { PluginPage } from '@/components/plugins/PluginLoader';
import { useAuthStore } from '@/store/authStore';
import { import {
type UnifiedContact, type UnifiedContact,
type ContactPerson, type ContactPerson,
@@ -135,6 +139,11 @@ function ContactPersonModal({
); );
} }
function getIcon(name: string): React.ReactNode {
const Icon = (LucideIcons as any)[name];
return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />;
}
export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) { export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
@@ -144,6 +153,9 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
const deletePersonMutation = useDeleteContactPerson(); const deletePersonMutation = useDeleteContactPerson();
const [personModalOpen, setPersonModalOpen] = useState(false); const [personModalOpen, setPersonModalOpen] = useState(false);
const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null); const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null);
const [activeTab, setActiveTab] = useState('details');
const pluginTabs = usePluginStore(s => s.getDetailTabsForEntity('contact'));
const user = useAuthStore(s => s.user);
if (loading) { if (loading) {
return ( 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 ( return (
<div className="overflow-y-auto h-full" data-testid="contact-detail"> <div className="overflow-y-auto h-full" data-testid="contact-detail">
{/* Header */} {/* Header */}
@@ -224,6 +251,27 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
</div> </div>
</div> </div>
{/* Tab Bar */}
<div className="flex border-b border-secondary-200 bg-white sticky top-[58px] z-10 overflow-x-auto" role="tablist">
{allTabs.map(tab => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-500 text-primary-700'
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300'
}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.icon && getIcon(tab.icon)}
{tab.label}
</button>
))}
</div>
{activeTab === 'details' ? (
<div className="p-4"> <div className="p-4">
{/* Contact Persons */} {/* Contact Persons */}
<Section <Section
@@ -367,6 +415,19 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
</Section> </Section>
)} )}
</div> </div>
) : (
<div className="p-4">
{allTabs.filter((t): t is { id: string; label: string; icon: string; component: string } => t.id !== 'details').map(tab => (
activeTab === tab.id && (
<PluginPage
key={tab.id}
component={tab.component}
pluginName={tab.label}
/>
)
))}
</div>
)}
<ContactPersonModal <ContactPersonModal
open={personModalOpen} open={personModalOpen}
@@ -6,6 +6,7 @@ import { PluginToolbar } from './PluginToolbar';
import { MessageSidebar } from './MessageSidebar'; import { MessageSidebar } from './MessageSidebar';
import { ToastContainer } from '@/components/ui/Toast'; import { ToastContainer } from '@/components/ui/Toast';
import { useAIContext } from '@/hooks/useAIContext'; import { useAIContext } from '@/hooks/useAIContext';
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
export function AppShell() { export function AppShell() {
const location = useLocation(); const location = useLocation();
@@ -18,6 +19,7 @@ export function AppShell() {
return ( return (
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell"> <div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
<PluginRegistry />
<Sidebar /> <Sidebar />
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<TopBar /> <TopBar />
+106
View File
@@ -4,6 +4,8 @@ import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useUIStore } from '@/store/uiStore'; import { useUIStore } from '@/store/uiStore';
import { Calendar, ChevronRight, FileText, Home, Mail, Monitor, Users } from 'lucide-react'; import { Calendar, ChevronRight, FileText, Home, Mail, Monitor, Users } from 'lucide-react';
import { usePluginStore } from '@/store/pluginStore';
import * as LucideIcons from 'lucide-react';
interface NavLeaf { interface NavLeaf {
to: string; to: string;
@@ -26,6 +28,11 @@ const chevronIcon = (expanded: boolean) => (
<ChevronRight className={clsx('w-4 h-4 flex-shrink-0 transition-transform duration-200', expanded ? 'rotate-90' : '')} aria-hidden="true" strokeWidth={2} /> <ChevronRight className={clsx('w-4 h-4 flex-shrink-0 transition-transform duration-200', expanded ? 'rotate-90' : '')} aria-hidden="true" strokeWidth={2} />
); );
function getIcon(name: string): React.ReactNode {
const Icon = (LucideIcons as any)[name];
return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />;
}
const singleItems: NavSingleItem[] = [ const singleItems: NavSingleItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> }, { to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> }, { to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
@@ -66,6 +73,7 @@ export function Sidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
const { sidebarOpen, setSidebarOpen } = useUIStore(); const { sidebarOpen, setSidebarOpen } = useUIStore();
const location = useLocation(); const location = useLocation();
const pluginMenuItems = usePluginStore(s => s.getAllMenuItems());
const initialExpanded = treeItems const initialExpanded = treeItems
.filter((item) => item.children.some((child) => location.pathname.startsWith(child.to))) .filter((item) => item.children.some((child) => location.pathname.startsWith(child.to)))
@@ -196,6 +204,104 @@ export function Sidebar() {
); );
})} })}
{pluginMenuItems.length > 0 && (
<>
<li className="px-3 pt-4 pb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-secondary-500">
{t('nav.plugins', 'Plugins')}
</span>
</li>
{(() => {
const groups = new Map<string, typeof pluginMenuItems>();
const singles: typeof pluginMenuItems = [];
for (const item of pluginMenuItems) {
if (item.group) {
if (!groups.has(item.group)) groups.set(item.group, []);
groups.get(item.group)!.push(item);
} else {
singles.push(item);
}
}
const elements: React.ReactNode[] = [];
for (const item of singles) {
elements.push(
<li key={item.path}>
<NavLink
to={item.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white'
: 'hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(item.label_key, item.label)}
>
{getIcon(item.icon)}
<span className="truncate">{t(item.label_key, item.label)}</span>
</NavLink>
</li>
);
}
for (const [group, items] of groups) {
const groupKey = `plugin-group-${group}`;
const expanded = expandedItems.has(groupKey);
elements.push(
<li key={groupKey}>
<div
className={clsx(
'flex items-center gap-2 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors cursor-pointer select-none',
'text-secondary-300 hover:bg-secondary-800 hover:text-white'
)}
onClick={() => toggleExpand(groupKey)}
role="button"
tabIndex={0}
aria-expanded={expanded}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(groupKey);
}
}}
>
{getIcon(items[0].icon)}
<span className="flex-1 truncate">{group}</span>
{chevronIcon(expanded)}
</div>
{expanded && (
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
{items.map((child) => (
<li key={child.path}>
<NavLink
to={child.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white font-medium'
: 'text-secondary-400 hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(child.label_key, child.label)}
>
<span className="truncate">{t(child.label_key, child.label)}</span>
</NavLink>
</li>
))}
</ul>
)}
</li>
);
}
return elements;
})()}
</>
)}
{bottomItems.map((item) => ( {bottomItems.map((item) => (
<li key={item.to}> <li key={item.to}>
<NavLink <NavLink
@@ -0,0 +1,84 @@
import React, { Suspense, lazy, Component, ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
// ── Error Boundary ──────────────────────────────────────────────────────
interface PluginErrorBoundaryProps {
children: ReactNode;
pluginName: string;
}
interface PluginErrorBoundaryState {
hasError: boolean;
}
class PluginErrorBoundary extends Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
state = { hasError: false };
static getDerivedStateFromError(): PluginErrorBoundaryState {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 text-sm text-red-600" role="alert">
Failed to load plugin: {this.props.pluginName}
</div>
);
}
return this.props.children;
}
}
// ── Lazy Component Cache ────────────────────────────────────────────────
const componentCache = new Map<string, React.LazyExoticComponent<React.ComponentType<any>>>();
function getLazyComponent(componentPath: string): React.LazyExoticComponent<React.ComponentType<any>> {
if (componentCache.has(componentPath)) {
return componentCache.get(componentPath)!;
}
// Convert @/pages/Mail to ../pages/Mail for dynamic import
const importPath = componentPath.replace(/^@\//, '../');
const LazyComp = lazy(() =>
import(/* @vite-ignore */ importPath).then((m) => ({
default: m.default || m[Object.keys(m)[0]],
}))
);
componentCache.set(componentPath, LazyComp);
return LazyComp;
}
// ── PluginPage Component ───────────────────────────────────────────────
interface PluginPageProps {
component: string;
pluginName: string;
}
/**
* PluginPage — dynamically loads a plugin page component using React.lazy().
* Wraps the lazy component in Suspense with a centered spinner fallback
* and an ErrorBoundary that shows a fallback message on failure.
*/
export function PluginPage({ component, pluginName }: PluginPageProps) {
const LazyComp = getLazyComponent(component);
return (
<PluginErrorBoundary pluginName={pluginName}>
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="animate-spin h-8 w-8 text-primary-500" />
</div>
}
>
<LazyComp />
</Suspense>
</PluginErrorBoundary>
);
}
@@ -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;
}
@@ -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: <PluginRouteRenderer /> }
*/
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 (
<PluginPage
component={matchedRoute.component}
pluginName={pluginName}
/>
);
}
// 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 (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-secondary-500">
<h2 className="text-2xl font-semibold mb-2">Page Not Found</h2>
<p className="text-sm">
The page <code className="bg-secondary-100 px-1 rounded">{location.pathname}</code> was not found.
</p>
</div>
);
}
@@ -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(<PluginPage component="@/pages/NonExistentPage" pluginName="broken" />);
// 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(<PluginPage component="@/pages/AnotherMissingPage" pluginName="my_custom_plugin" />);
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(<PluginPage component="@/pages/NonExistentPage" pluginName="test" />);
// 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(<PluginPage component="" pluginName="empty" />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Failed to load plugin: empty');
});
it('error boundary has role="alert" for accessibility', async () => {
render(<PluginPage component="@/pages/MissingPage" pluginName="test" />);
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(<PluginPage component="@/pages/YetAnotherMissing" pluginName="plugin_x" />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('plugin_x');
});
});
@@ -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(<PluginRegistry />);
expect(container.innerHTML).toBe('');
});
it('populates store with manifests on mount', () => {
render(<PluginRegistry />);
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(<PluginRegistry />);
expect(usePluginStore.getState().loading).toBe(true);
});
it('sets error state on fetch failure', () => {
mockQueryResult = { data: null, isLoading: false, error: new Error('Network error') };
render(<PluginRegistry />);
expect(usePluginStore.getState().error).toBe('Network error');
});
it('handles empty manifests gracefully', () => {
mockQueryResult = { data: { plugins: [], total: 0 }, isLoading: false, error: null };
render(<PluginRegistry />);
const manifests = usePluginStore.getState().manifests;
expect(manifests).toHaveLength(0);
expect(usePluginStore.getState().loaded).toBe(true);
});
});
@@ -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 }) => (
<div data-testid="plugin-page" data-component={component} data-plugin={pluginName}>
Plugin: {pluginName}
</div>
),
}));
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(
<MemoryRouter initialEntries={['/mail']}>
<PluginRouteRenderer />
</MemoryRouter>
);
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(
<MemoryRouter initialEntries={['/mail/settings']}>
<PluginRouteRenderer />
</MemoryRouter>
);
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(
<MemoryRouter initialEntries={['/unknown']}>
<PluginRouteRenderer />
</MemoryRouter>
);
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(
<MemoryRouter initialEntries={['/mail']}>
<PluginRouteRenderer />
</MemoryRouter>
);
expect(container.innerHTML).toBe('');
});
it('matches the first route when multiple match', () => {
usePluginStore.setState({
manifests: mockManifests,
loaded: true,
});
render(
<MemoryRouter initialEntries={['/calendar']}>
<PluginRouteRenderer />
</MemoryRouter>
);
expect(screen.getByText('Plugin: Calendar')).toBeInTheDocument();
});
});
+18 -1
View File
@@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePluginStore } from '@/store/pluginStore';
import * as LucideIcons from 'lucide-react';
export function SettingsPage() { export function SettingsPage() {
const { t } = useTranslation(); 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/system', label: t('systemSettings.title'), icon: '\u2699\ufe0f' },
{ to: '/settings/roles', label: t('settings.roles'), icon: '\ud83d\udd11' }, { to: '/settings/roles', label: t('settings.roles'), icon: '\ud83d\udd11' },
{ to: '/settings/users', label: t('settings.users'), icon: '\ud83d\udc65' }, { 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' }, { 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 ( return (
<div className="flex max-w-7xl mx-auto" data-testid="settings-page"> <div className="flex max-w-7xl mx-auto" data-testid="settings-page">
<aside className="w-64 flex-shrink-0 border-r border-secondary-200 p-4 min-h-[calc(100vh-4rem)]"> <aside className="w-64 flex-shrink-0 border-r border-secondary-200 p-4 min-h-[calc(100vh-4rem)]">
+129 -2
View File
@@ -1,6 +1,14 @@
import React, { useState } from 'react'; import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlugins, useInstallPlugin, useActivatePlugin, useDeactivatePlugin, useUninstallPlugin } from '@/api/hooks'; import {
usePlugins,
useInstallPlugin,
useActivatePlugin,
useDeactivatePlugin,
useUninstallPlugin,
useUploadPlugin,
useInstallPluginFromUrl,
} from '@/api/hooks';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
@@ -44,6 +52,122 @@ function statusLabel(status: string, t: (k: string) => string): string {
} }
} }
function InstallPluginSection() {
const { t } = useTranslation();
const toast = useToast();
const uploadMutation = useUploadPlugin();
const installUrlMutation = useInstallPluginFromUrl();
const fileInputRef = useRef<HTMLInputElement>(null);
const [url, setUrl] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
setSelectedFile(file);
};
const handleUpload = async () => {
if (!selectedFile) {
toast.error(t('settings.pluginSelectFile'));
return;
}
try {
await uploadMutation.mutateAsync(selectedFile);
toast.success(t('settings.pluginUploadedSuccess'));
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleInstallUrl = async () => {
if (!url.trim()) {
toast.error(t('settings.pluginEnterUrl'));
return;
}
try {
await installUrlMutation.mutateAsync(url.trim());
toast.success(t('settings.pluginUrlInstalledSuccess'));
setUrl('');
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const isBusy = uploadMutation.isPending || installUrlMutation.isPending;
return (
<Card className="mb-6">
<div className="p-4">
<h2 className="text-lg font-semibold text-secondary-900 mb-4">
{t('settings.installPlugin')}
</h2>
{/* ZIP Upload */}
<div className="mb-4">
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('settings.uploadZip')}
</label>
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleFileSelect}
className="block w-full text-sm text-secondary-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
data-testid="plugin-zip-upload-input"
/>
<Button
size="sm"
onClick={handleUpload}
isLoading={uploadMutation.isPending}
disabled={!selectedFile || isBusy}
data-testid="plugin-upload-btn"
>
{t('settings.upload')}
</Button>
</div>
{selectedFile && (
<p className="text-xs text-secondary-400 mt-1">
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
</p>
)}
</div>
{/* URL Install */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('settings.installFromUrl')}
</label>
<div className="flex items-center gap-3">
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/plugin.zip"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
data-testid="plugin-url-input"
/>
<Button
size="sm"
onClick={handleInstallUrl}
isLoading={installUrlMutation.isPending}
disabled={!url.trim() || isBusy}
data-testid="plugin-install-url-btn"
>
{t('settings.install')}
</Button>
</div>
</div>
</div>
</Card>
);
}
export function SettingsPluginsPage() { export function SettingsPluginsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
@@ -133,6 +257,9 @@ export function SettingsPluginsPage() {
</Button> </Button>
</div> </div>
{/* Install Plugin Section */}
<InstallPluginSection />
{plugins.length === 0 ? ( {plugins.length === 0 ? (
<EmptyState title={t('settings.noPlugins')} /> <EmptyState title={t('settings.noPlugins')} />
) : ( ) : (
+3
View File
@@ -6,6 +6,7 @@ import { LoginPage } from '@/pages/Login';
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest'; import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm'; import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { PluginRouteRenderer } from '@/components/plugins/PluginRouteRenderer';
// Lazy-loaded pages (code-splitting) // Lazy-loaded pages (code-splitting)
const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage }))); const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage })));
@@ -100,8 +101,10 @@ const router = createBrowserRouter([
{ path: 'ai', element: withSuspense(<AISettingsPage />) }, { path: 'ai', element: withSuspense(<AISettingsPage />) },
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) }, { path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
{ path: 'theme', element: withSuspense(<SettingsThemePage />) }, { path: 'theme', element: withSuspense(<SettingsThemePage />) },
{ path: '*', element: <PluginRouteRenderer /> },
], ],
}, },
{ path: '*', element: <PluginRouteRenderer /> },
], ],
}, },
]); ]);
@@ -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);
});
});
});
+128
View File
@@ -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<PluginState>((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);
},
}));
+60
View File
@@ -0,0 +1,60 @@
# Plugin Template
A minimal plugin template for LeoCRM. Copy this directory to `app/plugins/builtins/<your_plugin_name>/` 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_<event_name>` 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
+1
View File
@@ -0,0 +1 @@
"""Plugin template package."""
@@ -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);
+38
View File
@@ -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")
+178
View File
@@ -0,0 +1,178 @@
"""
Plugin Template — Example plugin demonstrating all manifest fields.
Copy this directory to app/plugins/builtins/<your_plugin_name>/ 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,
},
]
+48
View File
@@ -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"}
+50
View File
@@ -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
+39
View File
@@ -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
@@ -0,0 +1 @@
"""Tests for the example plugin."""
@@ -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