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: {} },