diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py index c2ccaf9..4389e5d 100644 --- a/app/plugins/builtins/automation/plugin.py +++ b/app/plugins/builtins/automation/plugin.py @@ -102,7 +102,7 @@ class AutomationPlugin(BasePlugin): path="/workflows", icon="Workflow", order=52, - permission="automation:read", + permission="workflows:read", ), FrontendMenuItem( label_key="nav.importExport", @@ -137,24 +137,15 @@ class AutomationPlugin(BasePlugin): permission="contacts:read", ), ], + # Phase Q1: /agents and /automation are served by the static + # StartLayout hub trees (sub-navigation). Flat manifest entries for + # them were dead duplicates (never matched) and were removed. page_routes=[ - FrontendPageRoute( - path="/automation", - component="@/pages/AutomationDashboard", - order=50, - permission="automation:read", - ), - FrontendPageRoute( - path="/agents", - component="@/pages/AgentDashboard", - order=51, - permission="agents:read", - ), FrontendPageRoute( path="/workflows", component="@/pages/Workflows", order=52, - permission="automation:read", + permission="workflows:read", ), FrontendPageRoute( path="/import-export", diff --git a/app/plugins/builtins/calendar/plugin.py b/app/plugins/builtins/calendar/plugin.py index 74006d9..4f0542a 100644 --- a/app/plugins/builtins/calendar/plugin.py +++ b/app/plugins/builtins/calendar/plugin.py @@ -64,6 +64,8 @@ class CalendarPlugin(BasePlugin): ], page_routes=[ FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True, permission='calendar:read'), + # Q1: kanban view was static-only before - now manifest-declared. + FrontendPageRoute(path='/calendar/kanban', component='@/pages/CalendarKanban', protected=True, permission='calendar:read'), ], # BUG (ghost component): ContactCalendarTab does not exist in the # frontend — tab removed until implemented (Block I-D). diff --git a/app/plugins/builtins/tags/plugin.py b/app/plugins/builtins/tags/plugin.py index 8fdebf9..08445b1 100644 --- a/app/plugins/builtins/tags/plugin.py +++ b/app/plugins/builtins/tags/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations from app.plugins.base import BasePlugin -from app.plugins.manifest import PluginManifest, PluginRouteDef +from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef class TagsPlugin(BasePlugin): @@ -30,6 +30,26 @@ class TagsPlugin(BasePlugin): "tags:delete", "tags:admin", ], + # Q1: the /tags page was a static-only route before - now + # manifest-declared (route + sidebar menu item). + menu_items=[ + FrontendMenuItem( + label_key="nav.tags", + label="Tags", + path="/tags", + icon="Tags", + order=80, + permission="tags:read", + ), + ], + page_routes=[ + FrontendPageRoute( + path="/tags", + component="@/pages/Tags", + protected=True, + permission="tags:read", + ), + ], is_core=True, # BUG (ghost component): ContactTagsTab does not exist in the # frontend — tab removed until implemented (Block I-D). diff --git a/frontend/src/components/plugins/PluginRouteRenderer.tsx b/frontend/src/components/plugins/PluginRouteRenderer.tsx index 147154a..2a7493d 100644 --- a/frontend/src/components/plugins/PluginRouteRenderer.tsx +++ b/frontend/src/components/plugins/PluginRouteRenderer.tsx @@ -22,7 +22,19 @@ import { PluginPage } from './PluginLoader'; * route group in routes/index.tsx: * { path: '*', element: } */ -export function PluginRouteRenderer() { +/** + * variant: + * - 'pages' (default): renders manifest page_routes. Mounted at the + * AppShell catch-all — descendant match the full pathname, + * so entries keep their absolute paths (e.g. '/mail'). + * - 'settings': renders manifest settings_pages. Mounted INSIDE the + * /settings subtree catch-all — descendant matching works on the + * REMAINING pathname ('/mail' after the '/settings' prefix is stripped), + * so entries keep the bare settings sub-segment ('mail'). Mixing both + * entry kinds in one list would create path collisions (e.g. settings + * 'mail' vs. page '/mail' rendering the inbox). + */ +export function PluginRouteRenderer({ variant = 'pages' }: { variant?: 'pages' | 'settings' } = {}) { const location = useLocation(); const manifests = usePluginStore((s) => s.manifests); const loaded = usePluginStore((s) => s.loaded); @@ -30,16 +42,33 @@ export function PluginRouteRenderer() { // Flatten manifests → route entries with their owning plugin attached. // Attaching the name here (instead of a later find()) stays correct even // if two plugins declare overlapping paths. - const entries = useMemo( - () => - manifests.flatMap((m) => - m.page_routes.map((r) => ({ - route: r, - pluginName: m.display_name || m.name || r.path, + type RendererEntry = { + route: { path: string; component: string; permission?: string }; + pluginName: string; + }; + + const entries = useMemo(() => { + if (variant === 'settings') { + return manifests.flatMap((m) => + (Array.isArray(m.settings_pages) ? m.settings_pages : []).map((p) => ({ + route: { + // bare sub-segment ('mail') — descendant matching strips the + // matched '/settings' prefix; tolerate 'settings/mail' too. + path: p.path.replace(/^\/?settings\//, ''), + component: p.component, + permission: p.permission || undefined, + }, + pluginName: m.display_name || m.name || p.path, })) - ), - [manifests] - ); + ); + } + return manifests.flatMap((m) => + m.page_routes.map((r) => ({ + route: r, + pluginName: m.display_name || m.name || r.path, + })) + ); + }, [manifests, variant]); // If manifests haven't loaded yet, show a spinner (not null/blank) if (!loaded) { diff --git a/frontend/src/generated/pluginComponents.generated.ts b/frontend/src/generated/pluginComponents.generated.ts index 4455d61..1cbff66 100644 --- a/frontend/src/generated/pluginComponents.generated.ts +++ b/frontend/src/generated/pluginComponents.generated.ts @@ -31,10 +31,9 @@ export const PLUGIN_COMPONENT_MAP: Record = { '@/components/dashboard/WikiRecentWidget': () => import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget })), '@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule), '@/pages/AISettings': () => import('@/pages/AISettings').then((m) => ({ default: m.AISettingsPage })), - '@/pages/AgentDashboard': () => import('@/pages/AgentDashboard').then((m) => ({ default: m.AgentDashboardPage })), - '@/pages/AutomationDashboard': () => import('@/pages/AutomationDashboard').then((m) => ({ default: m.AutomationDashboardPage })), '@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then((m) => ({ default: m.AutomationSettingsPage })), '@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule), + '@/pages/CalendarKanban': () => import('@/pages/CalendarKanban').then(normalizeModule), '@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule), '@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })), '@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })), @@ -52,6 +51,7 @@ export const PLUGIN_COMPONENT_MAP: Record = { '@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then((m) => ({ default: m.SettingsNotificationsPage })), '@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then((m) => ({ default: m.SettingsRolesPage })), '@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then((m) => ({ default: m.SettingsUsersPage })), + '@/pages/Tags': () => import('@/pages/Tags').then((m) => ({ default: m.TagsPage })), '@/pages/Tasks': () => import('@/pages/Tasks').then((m) => ({ default: m.TasksPage })), '@/pages/Wiki': () => import('@/pages/Wiki').then((m) => ({ default: m.WikiPage })), '@/pages/Workflows': () => import('@/pages/Workflows').then((m) => ({ default: m.WorkflowsPage })), diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index f5f5935..4fc12e8 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,4 +1,6 @@ -// TODO: P2-F1 — Replace hardcoded plugin routes with dynamic PluginRouteRenderer +// Phase Q1 (2026-09-13): plugin routes are served dynamically from the manifests +// via PluginRouteRenderer. Only core routes and the StartLayout hub trees +// (/agents, /automation, /logs, /help — sub-navigation layouts) stay static. import React, { Suspense } from 'react'; import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'; import { AppShell } from '@/components/layout/AppShell'; @@ -17,25 +19,14 @@ import { ErrorBoundary } from '@/components/common/ErrorBoundary'; // Lazy-loaded pages (code-splitting) const DashboardPage = React.lazy(() => import('@/pages/Dashboard').then(m => ({ default: m.DashboardPage }))); const AuditLogPage = React.lazy(() => import('@/pages/AuditLog').then(m => ({ default: m.AuditLogPage }))); -const GlobalSearchResultsPage = React.lazy(() => import('@/pages/GlobalSearchResults').then(m => ({ default: m.GlobalSearchResultsPage }))); const SettingsPage = React.lazy(() => import('@/pages/Settings').then(m => ({ default: m.SettingsPage }))); const SettingsProfilePage = React.lazy(() => import('@/pages/SettingsProfile').then(m => ({ default: m.SettingsProfilePage }))); -const SettingsRolesPage = React.lazy(() => import('@/pages/SettingsRoles').then(m => ({ default: m.SettingsRolesPage }))); -const SettingsUsersPage = React.lazy(() => import('@/pages/SettingsUsers').then(m => ({ default: m.SettingsUsersPage }))); -const SettingsGroupsPage = React.lazy(() => import('@/pages/SettingsGroups').then(m => ({ default: m.SettingsGroupsPage }))); const SettingsPluginsPage = React.lazy(() => import('@/pages/SettingsPlugins').then(m => ({ default: m.SettingsPluginsPage }))); const SettingsSystemPage = React.lazy(() => import('@/pages/SettingsSystem').then(m => ({ default: m.SettingsSystemPage }))); const SettingsCurrenciesPage = React.lazy(() => import('@/pages/SettingsCurrencies').then(m => ({ default: m.SettingsCurrenciesPage }))); const SettingsTaxesPage = React.lazy(() => import('@/pages/SettingsTaxes').then(m => ({ default: m.SettingsTaxesPage }))); const SettingsSequencesPage = React.lazy(() => import('@/pages/SettingsSequences').then(m => ({ default: m.SettingsSequencesPage }))); -const CalendarPage = React.lazy(() => import('@/pages/Calendar').then(m => ({ default: m.CalendarPage }))); -const CalendarKanbanPage = React.lazy(() => import('@/pages/CalendarKanban').then(m => ({ default: m.CalendarKanbanPage }))); -const DmsPage = React.lazy(() => import('@/pages/Dms').then(m => ({ default: m.DmsPage }))); -const DmsTrashPage = React.lazy(() => import('@/pages/DmsTrash').then(m => ({ default: m.DmsTrashPage }))); const TrashPage = React.lazy(() => import('@/pages/Trash').then(m => ({ default: m.TrashPage }))); -const MailPage = React.lazy(() => import('@/pages/Mail').then(m => ({ default: m.MailPage }))); -const MailSettingsPage = React.lazy(() => import('@/pages/MailSettings').then(m => ({ default: m.MailSettingsPage }))); -const SettingsNotificationsPage = React.lazy(() => import('@/pages/SettingsNotifications').then(m => ({ default: m.SettingsNotificationsPage }))); const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage }))); const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings }))); const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage }))); @@ -49,15 +40,7 @@ const SettingsMenuOrderPage = React.lazy(() => import('@/pages/SettingsMenuOrder const SettingsStammdatenPage = React.lazy(() => import('@/pages/SettingsStammdaten').then(m => ({ default: m.SettingsStammdatenPage }))); const SettingsUserManagementPage = React.lazy(() => import('@/pages/SettingsUserManagement').then(m => ({ default: m.SettingsUserManagementPage }))); const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage }))); -const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage }))); const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage }))); -const ReportsPage = React.lazy(() => import('@/pages/Reports').then(m => ({ default: m.ReportsPage }))); -const DocumentSettingsPage = React.lazy(() => import('@/pages/DocumentSettings').then(m => ({ default: m.DocumentSettingsPage }))); -const TasksPage = React.lazy(() => import('@/pages/Tasks').then(m => ({ default: m.TasksPage }))); -const CommunicationPage = React.lazy(() => import('@/pages/Communication').then(m => ({ default: m.CommunicationPage }))); -const WorkflowsPage = React.lazy(() => import('@/pages/Workflows').then(m => ({ default: m.WorkflowsPage }))); -const ImportExportPage = React.lazy(() => import('@/pages/ImportExport').then(m => ({ default: m.ImportExportPage }))); -const TagsPage = React.lazy(() => import('@/pages/Tags').then(m => ({ default: m.TagsPage }))); const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m => ({ default: m.CustomFieldsPage }))); const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage }))); const ApprovalsPage = React.lazy(() => import('@/pages/Approvals').then(m => ({ default: m.ApprovalsPage }))); @@ -85,7 +68,6 @@ const LogsOverviewPage = React.lazy(() => import('@/pages/logs/LogsOverview').th const LogsPlaceholderPage = React.lazy(() => import('@/pages/logs/LogsPlaceholder').then(m => ({ default: m.LogsPlaceholderPage }))); const HelpApiDocsPage = React.lazy(() => import('@/pages/help/HelpApiDocs').then(m => ({ default: m.HelpApiDocsPage }))); const ApiDocsPage = React.lazy(() => import('@/pages/ApiDocs').then(m => ({ default: m.ApiDocsPage }))); -const WikiPage = React.lazy(() => import('@/pages/Wiki').then(m => ({ default: m.WikiPage }))); const SystemDashboardPage = React.lazy(() => import('@/pages/SystemDashboard').then(m => ({ default: m.SystemDashboardPage }))); /** Centered spinner fallback for lazy-loaded routes */ @@ -205,30 +187,25 @@ const router = createBrowserRouter([ children: [ { path: 'stammdaten', element: withSuspense() }, { path: 'user-management', element: withSuspense() }, - { path: 'roles', element: withSuspense() }, - { path: 'users', element: withSuspense() }, - { path: 'groups', element: withSuspense() }, + // Phase Q2: plugin settings sub-routes (roles, users, groups, mail, + // notifications, ai, ai-proactive, automation, documents) are served + // by the manifests via PluginRouteRenderer - single source of truth. { path: 'plugins', element: withSuspense() }, { path: 'system', element: withSuspense() }, { path: 'currencies', element: withSuspense() }, { path: 'taxes', element: withSuspense() }, { path: 'sequences', element: withSuspense() }, - { path: 'mail', element: withSuspense() }, - { path: 'notifications', element: withSuspense() }, - { path: 'ai', element: withSuspense() }, - { path: 'ai-proactive', element: withSuspense() }, { path: 'theme', element: withSuspense() }, - { path: 'automation', element: withSuspense() }, { path: 'mcp', element: withSuspense() }, { path: 'ai-settings', element: withSuspense() }, { path: 'menu', element: withSuspense() }, { path: 'custom-fields', element: withSuspense() }, { path: 'webhooks', element: withSuspense() }, { path: 'workspaces', element: withSuspense() }, - { path: 'documents', element: withSuspense() }, { path: 'backup', element: withSuspense() }, { path: 'rechte', element: {withSuspense()} }, - { path: '*', element: {} }, + // Phase Q2: plugin settings sub-pages render with bare sub-segments + { path: '*', element: {} }, ], }, ], @@ -243,24 +220,14 @@ const router = createBrowserRouter([ { path: '/', element: }, { path: '/dashboard', element: withSuspense() }, { path: '/audit-log', element: {withSuspense()} }, - { path: '/search', element: withSuspense() }, - { path: '/calendar', element: {withSuspense()} }, - { path: '/calendar/kanban', element: {withSuspense()} }, - { path: '/dms', element: {withSuspense()} }, - { path: '/dms/trash', element: {withSuspense()} }, + // Phase Q1: plugin business routes (search, calendar + kanban, dms, + // dms/trash, mail, mail/settings, reports, tasks, communication, + // workflows, import-export, tags, wiki) are served by the manifests + // via PluginRouteRenderer below - single source of truth. { path: '/trash', element: {withSuspense()} }, - { path: '/mail', element: {withSuspense()} }, - { path: '/mail/settings', element: {withSuspense()} }, - { path: '/reports', element: {withSuspense()} }, - { path: '/tasks', element: {withSuspense()} }, - { path: '/communication', element: {withSuspense()} }, - { path: '/workflows', element: {withSuspense()} }, - { path: '/import-export', element: {withSuspense()} }, - { path: 'tags', element: {withSuspense()} }, { path: '/approvals', element: {withSuspense()} }, { path: '/api-docs', element: {withSuspense()} }, { path: '/activity', element: {withSuspense()} }, - { path: '/wiki', element: {withSuspense()} }, { path: '/system-dashboard', element: withSuspense() }, { path: '/profile', element: withSuspense() }, { path: '*', element: {} },