From 9312e76737e22de34c5239e1b7ab4b32aa170a5e Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Thu, 12 Feb 2026 00:34:25 +0100 Subject: [PATCH] fixed rbac issues and sysadmin integration --- src/App.tsx | 3 +- src/api.ts | 6 +- .../FormGeneratorReport.tsx | 8 +- src/config/pageRegistry.tsx | 34 +-- src/hooks/playground/useDashboardInputForm.ts | 33 +-- src/hooks/useAutomations.ts | 6 + src/pages/Dashboard.tsx | 74 +++--- src/pages/GDPR.module.css | 9 +- src/pages/Settings.module.css | 2 + src/pages/Settings.tsx | 5 +- src/pages/admin/Admin.module.css | 7 +- src/pages/admin/AdminAutomationEventsPage.tsx | 223 ++++++++++++++++++ src/pages/admin/AdminInvitationsPage.tsx | 33 ++- .../admin/AdminMandateRolePermissionsPage.tsx | 109 ++++++++- src/pages/admin/index.ts | 3 +- .../workflows/AutomationTemplatesPage.tsx | 59 ++++- src/pages/workflows/AutomationsPage.tsx | 33 ++- 17 files changed, 538 insertions(+), 109 deletions(-) create mode 100644 src/pages/admin/AdminAutomationEventsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index de77ea8..17aa5ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,7 +41,7 @@ import { DashboardPage } from './pages/Dashboard'; import { SettingsPage } from './pages/Settings'; import { GDPRPage } from './pages/GDPR'; import { FeatureViewPage } from './pages/FeatureView'; -import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin'; +import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage } from './pages/admin'; // Basedata Pages (global) import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; @@ -178,6 +178,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/api.ts b/src/api.ts index 4a37764..55d54c0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -21,8 +21,12 @@ const resolveHostnameToIP = async (hostname: string): Promise => }; /** - * Extract mandate/instance context from current URL + * Extract mandate/instance context from current URL. * URL pattern: /mandates/:mandateId/:featureCode/:instanceId/... + * + * Only feature pages under /mandates/... provide context via URL. + * Admin pages (e.g., /admin/users) do NOT send mandate context -- + * admin endpoints aggregate across all user mandates server-side. */ const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => { const pathname = window.location.pathname; diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx index acfb92a..2e42ad8 100644 --- a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx @@ -128,7 +128,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): return (
- + - + - + - + = { 'page.system.home': , 'page.system.settings': , 'page.system.gdpr': , - 'page.system.playground': , - 'page.system.chats': , - 'page.system.automations': , - 'page.system.automation-templates': , + + // Basedata pages (system-level) 'page.system.prompts': , 'page.system.files': , 'page.system.connections': , - 'page.system.chatbot': , - 'page.system.pek': , - 'page.system.speech': , // Billing pages 'page.billing.dashboard': , 'page.billing.transactions': , - // Admin pages + // Admin pages (kebab-case + camelCase variants for backend compatibility) 'page.admin.access': , 'page.admin.users': , 'page.admin.invitations': , 'page.admin.mandates': , 'page.admin.roles': , 'page.admin.role-permissions': , + 'page.admin.mandateRolePermissions': , 'page.admin.user-mandates': , + 'page.admin.userMandates': , 'page.admin.feature-roles': , + 'page.admin.featureRoles': , 'page.admin.feature-instances': , 'page.admin.featureInstances': , 'page.admin.feature-users': , 'page.admin.user-access-overview': , + 'page.admin.userAccessOverview': , 'page.admin.billing': , + 'page.admin.automationEvents': , + 'page.admin.automation-events': , // Feature pages - Trustee 'page.feature.trustee.dashboard': , @@ -82,7 +83,9 @@ export const PAGE_ICONS: Record = { 'feature.trustee': , 'feature.realestate': , 'feature.chatworkflow': , - 'feature.chatbot': , + 'feature.chatplayground': , + 'feature.automation': , + 'feature.chatbot': , }; // ============================================================================= @@ -91,10 +94,13 @@ export const PAGE_ICONS: Record = { /** * Get icon for a uiComponent code. - * Falls back to FaCog if not found. + * Returns null if not found -- missing icons should be added to PAGE_ICONS. */ export function getPageIcon(uiComponent: string): React.ReactNode { - return PAGE_ICONS[uiComponent] || ; + if (!PAGE_ICONS[uiComponent]) { + console.warn(`[pageRegistry] Missing icon for uiComponent: "${uiComponent}"`); + } + return PAGE_ICONS[uiComponent] || null; } /** diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index e39494a..712ae6e 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -32,8 +32,8 @@ export function useDashboardInputForm(instanceId: string) { const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null); const [selectedProviders, setSelectedProviders] = useState([]); // AI provider selection (multiselect) - const { checkPermission, canView } = usePermissions(); - const [playgroundUIPermission, setPlaygroundUIPermission] = useState(false); + const { checkPermission } = usePermissions(); + const [playgroundUIPermission, setPlaygroundUIPermission] = useState(true); const [chatWorkflowPermission, setChatWorkflowPermission] = useState(null); const [promptPermission, setPromptPermission] = useState(null); const [filePermission, setFilePermission] = useState(null); @@ -84,23 +84,23 @@ export function useDashboardInputForm(instanceId: string) { useEffect(() => { const checkPermissions = async () => { try { - const uiPerm = await canView('UI', 'ui.system.playground'); - setPlaygroundUIPermission(uiPerm); + // UI permission is already verified by the navigation/routing layer + // (FeatureAccess + instance role checked before page is reachable). + // We set it to true and load DATA permissions directly. + setPlaygroundUIPermission(true); - if (uiPerm) { - const chatWorkflowPerm = await checkPermission('DATA', 'ChatWorkflow'); - setChatWorkflowPermission(chatWorkflowPerm); - const promptPerm = await checkPermission('DATA', 'Prompt'); - setPromptPermission(promptPerm); - const filePerm = await checkPermission('DATA', 'FileItem'); - setFilePermission(filePerm); - } + const chatWorkflowPerm = await checkPermission('DATA', 'ChatWorkflow'); + setChatWorkflowPermission(chatWorkflowPerm); + const promptPerm = await checkPermission('DATA', 'Prompt'); + setPromptPermission(promptPerm); + const filePerm = await checkPermission('DATA', 'FileItem'); + setFilePermission(filePerm); } catch (error) { } }; checkPermissions(); - }, [canView, checkPermission]); + }, [checkPermission]); // Sync context -> lifecycle: When context selection changes, update lifecycle useEffect(() => { @@ -609,15 +609,16 @@ export function useDashboardInputForm(instanceId: string) { setOptimisticMessage(null); // Reset workflow lifecycle state resetWorkflow(); - // Clear context selection - clearWorkflowFromContext(); + // NOTE: Do NOT call clearWorkflowFromContext() here — this handler is + // triggered BY clearWorkflow() which already set the context to null. + // Calling it again would dispatch another 'workflowCleared' event → infinite recursion. }; window.addEventListener('workflowCleared', handleWorkflowCleared); return () => { window.removeEventListener('workflowCleared', handleWorkflowCleared); }; - }, [resetWorkflow, clearWorkflowFromContext]); + }, [resetWorkflow]); const handleWorkflowSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record } | null) => { if (item === null) { diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts index 1120885..ddb444f 100644 --- a/src/hooks/useAutomations.ts +++ b/src/hooks/useAutomations.ts @@ -540,6 +540,11 @@ export function useAutomationTemplates() { await deleteAutomationTemplateApi(request, templateId); }, [request]); + const duplicateTemplate = useCallback(async (templateId: string) => { + const response = await request('POST', `/api/automation-templates/${templateId}/duplicate`); + return response; + }, [request]); + const refetch = useCallback(async () => { await Promise.all([ fetchTemplates(), @@ -563,6 +568,7 @@ export function useAutomationTemplates() { createTemplate, updateTemplate, deleteTemplate, + duplicateTemplate, }; } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 686d86f..f7f5e06 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ * Dashboard Page * * System-Übersicht für den User. - * Zeigt alle verfügbaren Feature-Instanzen als Karten an. + * Zeigt alle verfügbaren Feature-Instanzen pro Mandant als Karten an. * Daten kommen vom Backend via GET /api/navigation. */ @@ -11,7 +11,7 @@ import { Link } from 'react-router-dom'; import useNavigation from '../hooks/useNavigation'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation'; import { getPageIcon } from '../config/pageRegistry'; -import { FaArrowRight } from 'react-icons/fa'; +import { FaArrowRight, FaBuilding } from 'react-icons/fa'; import styles from './Dashboard.module.css'; // ============================================================================= @@ -21,10 +21,9 @@ import styles from './Dashboard.module.css'; interface InstanceCardProps { instance: NavFeatureInstance; feature: MandateFeature; - mandateLabel: string; } -const InstanceCard: React.FC = ({ instance, feature, mandateLabel }) => { +const InstanceCard: React.FC = ({ instance, feature }) => { // Ersten verfügbaren View-Pfad vom Backend nehmen const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined; @@ -40,7 +39,6 @@ const InstanceCard: React.FC = ({ instance, feature, mandateL {feature.uiLabel}

{instance.uiLabel}

-

{mandateLabel}

@@ -94,25 +92,6 @@ export const DashboardPage: React.FC = () => { return ; } - // Gruppiere Instanzen nach Feature (über alle Mandate) - const featureGroups: { feature: MandateFeature; instances: { instance: NavFeatureInstance; mandateLabel: string }[] }[] = []; - const featureMap = new Map(); - - for (const mandate of mandates) { - for (const feature of mandate.features) { - const key = feature.uiComponent; - let group = featureMap.get(key); - if (!group) { - group = { feature, instances: [] }; - featureMap.set(key, group); - featureGroups.push(group); - } - for (const instance of feature.instances) { - group.instances.push({ instance, mandateLabel: mandate.uiLabel }); - } - } - } - return (
@@ -123,24 +102,35 @@ export const DashboardPage: React.FC = () => {
- {featureGroups.map(({ feature, instances }) => ( -
-

- {getPageIcon(feature.uiComponent)} - {feature.uiLabel} -

-
- {instances.map(({ instance, mandateLabel }) => ( - - ))} -
-
- ))} + {mandates + .filter(mandate => mandate.features.some(f => f.instances.length > 0)) + .map(mandate => { + // Alle Instanzen dieses Mandats sammeln (flach, ohne Feature-Gruppierung) + const mandateInstances: { instance: NavFeatureInstance; feature: MandateFeature }[] = []; + for (const feature of mandate.features) { + for (const instance of feature.instances) { + mandateInstances.push({ instance, feature }); + } + } + + return ( +
+

+ + {mandate.uiLabel} +

+
+ {mandateInstances.map(({ instance, feature }) => ( + + ))} +
+
+ ); + })}
); diff --git a/src/pages/GDPR.module.css b/src/pages/GDPR.module.css index 87f7ca4..1ca0a45 100644 --- a/src/pages/GDPR.module.css +++ b/src/pages/GDPR.module.css @@ -23,7 +23,7 @@ margin: 0; font-size: 1.75rem; font-weight: 700; - color: var(--text-primary, #1a1a1a); + color: var(--text-primary); } .titleIcon { @@ -60,7 +60,7 @@ margin: 0 0 1.25rem; font-size: 1rem; font-weight: 600; - color: var(--text-primary, #1a1a1a); + color: var(--text-primary); } .actions { @@ -82,7 +82,7 @@ .actionCard h3 { margin: 0; font-size: 0.95rem; - color: var(--text-primary, #1a1a1a); + color: var(--text-primary); } .actionCard p { @@ -145,7 +145,7 @@ .secondaryButton { background: var(--surface-color, #f5f5f5); - color: var(--text-primary, #1a1a1a); + color: var(--text-primary); border-color: var(--border-color, #d0d0d0); } @@ -214,6 +214,7 @@ .infoBlock h3 { margin: 0 0 0.75rem; font-size: 0.9rem; + color: var(--text-primary); } .infoBlock ul { diff --git a/src/pages/Settings.module.css b/src/pages/Settings.module.css index 2631d2a..a9479a3 100644 --- a/src/pages/Settings.module.css +++ b/src/pages/Settings.module.css @@ -427,6 +427,8 @@ :global(.dark-theme) .modalContent { background: var(--bg-dark, #111827); + border: 1px solid var(--border-dark, #374151); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3); } :global(.dark-theme) .modalHeader { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index cd215e1..12bdec7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -295,7 +295,10 @@ export const SettingsPage: React.FC = () => {
diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index 6e99486..09b8c0c 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -549,7 +549,12 @@ } .logStatus { - color: var(--primary-color, #f25843); + color: #1976d2; +} + +.logEntryError .logStatus, +.logEntryError .logMessage { + color: #d32f2f; } .logMessage { diff --git a/src/pages/admin/AdminAutomationEventsPage.tsx b/src/pages/admin/AdminAutomationEventsPage.tsx new file mode 100644 index 0000000..8daf3e0 --- /dev/null +++ b/src/pages/admin/AdminAutomationEventsPage.tsx @@ -0,0 +1,223 @@ +/** + * AdminAutomationEventsPage + * + * Admin page for viewing and managing automation scheduler events. + * SysAdmin-only: displays all automation definitions with scheduler status. + * Uses FormGeneratorTable for consistent look with other admin pages. + */ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { FaSync } from 'react-icons/fa'; +import api from '../../api'; +import styles from './Admin.module.css'; +import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; + +interface AutomationEvent { + eventId: string; + automationId: string; + name: string; + nextRunTime: string | null; + trigger: string | null; + createdBy: string; + mandate: string; + featureInstance: string; +} + +const _formatNextRun = (nextRunTime: string | null): string => { + if (!nextRunTime || nextRunTime === 'None') return ''; + try { + const date = new Date(nextRunTime); + return date.toLocaleString('de-CH', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return nextRunTime; + } +}; + +export const AdminAutomationEventsPage: React.FC = () => { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [syncResult, setSyncResult] = useState(null); + + const _fetchEvents = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await api.get('/api/admin/automation-events'); + // Map eventId to id for FormGeneratorTable compatibility + setEvents(response.data.map((e: any) => ({ ...e, id: e.eventId }))); + } catch (err: any) { + setError(err.response?.data?.detail || 'Fehler beim Laden der Events'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + _fetchEvents(); + }, [_fetchEvents]); + + const _handleSync = async () => { + try { + setSyncing(true); + setError(null); + setSyncResult(null); + const response = await api.post('/api/admin/automation-events/sync'); + const data = response.data; + setSyncResult(`Sync erfolgreich: ${data.synced} Automationen synchronisiert`); + await _fetchEvents(); + } catch (err: any) { + setError(err.response?.data?.detail || 'Fehler beim Synchronisieren'); + } finally { + setSyncing(false); + } + }; + + const _handleDelete = useCallback(async (eventId: string) => { + try { + setError(null); + const event = events.find(e => e.eventId === eventId); + const encodedId = encodeURIComponent(eventId); + await api.post(`/api/admin/automation-events/${encodedId}/remove`); + setEvents(prev => prev.filter(e => e.eventId !== eventId)); + } catch (err: any) { + setError(err.response?.data?.detail || 'Fehler beim Entfernen des Events'); + throw err; + } + }, [events]); + + const columns: ColumnConfig[] = useMemo(() => [ + { + key: 'name', + label: 'Name', + type: 'string' as const, + sortable: true, + searchable: true, + width: 200, + minWidth: 120, + }, + { + key: 'mandate', + label: 'Mandant', + type: 'string' as const, + sortable: true, + filterable: true, + width: 150, + minWidth: 100, + }, + { + key: 'createdBy', + label: 'Erstellt von', + type: 'string' as const, + sortable: true, + filterable: true, + width: 130, + minWidth: 80, + }, + { + key: 'featureInstance', + label: 'Feature', + type: 'string' as const, + sortable: true, + filterable: true, + width: 130, + minWidth: 80, + }, + { + key: 'nextRunTime', + label: 'Nächste Ausführung', + type: 'string' as const, + sortable: true, + width: 170, + minWidth: 130, + formatter: (value: any) => { + const formatted = _formatNextRun(value); + if (!formatted) return ; + return formatted; + }, + }, + { + key: 'trigger', + label: 'Trigger', + type: 'string' as const, + sortable: false, + width: 160, + minWidth: 100, + }, + ], []); + + return ( +
+
+
+

Automation Events

+

+ Aktive Scheduler-Jobs ({events.length} Events) +

+
+
+ + +
+
+ + {syncResult && ( +
+ + {syncResult} +
+ )} + + {error && ( +
+ ! + {error} +
+ )} + + +
+ ); +}; + +export default AdminAutomationEventsPage; diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 36d6c7d..7649250 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations'; import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates'; +import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAccess'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaEnvelopeOpenText, FaBuilding, FaCopy, FaLink } from 'react-icons/fa'; @@ -28,10 +29,12 @@ export const AdminInvitationsPage: React.FC = () => { } = useInvitations(); const { fetchMandates, fetchRoles } = useUserMandates(); + const { fetchInstances } = useFeatureAccess(); // State const [mandates, setMandates] = useState([]); const [selectedMandateId, setSelectedMandateId] = useState(''); + const [featureInstances, setFeatureInstances] = useState([]); const [roles, setRoles] = useState([]); const [showCreateModal, setShowCreateModal] = useState(false); const [showUrlModal, setShowUrlModal] = useState(null); @@ -58,16 +61,18 @@ export const AdminInvitationsPage: React.FC = () => { }).catch(() => setBackendAttributes([])); }, [fetchMandates]); - // Load invitations and roles when mandate changes + // Load invitations, feature instances, and roles when mandate changes useEffect(() => { if (selectedMandateId) { fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }); + fetchInstances(selectedMandateId).then(instances => { + setFeatureInstances(instances); + }); fetchRoles(selectedMandateId).then(fetchedRoles => { - console.warn('[AdminInvitations] fetchRoles result:', { mandateId: selectedMandateId, rolesCount: fetchedRoles.length, roles: fetchedRoles }); setRoles(fetchedRoles); }); } - }, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]); + }, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchInstances, fetchRoles]); // Format timestamp const formatDate = (timestamp: number) => { @@ -159,19 +164,29 @@ export const AdminInvitationsPage: React.FC = () => { }, ], [roles]); - // Form attributes from backend - merge with dynamic role options + // Form attributes from backend - merge with dynamic instance and role options const createFields: AttributeDefinition[] = useMemo(() => { const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl']; + + // Feature instance options + const instanceOptions = featureInstances.map(i => ({ + value: i.id, + label: i.label || `${i.featureCode} (${i.id.slice(0, 8)}...)` + })); + + // Instance-level roles (with featureInstanceId) const roleOptions = roles - .filter(r => !r.featureInstanceId) // Only mandate-level roles - .map(r => ({ value: r.id, label: r.roleLabel })); + .filter(r => !!r.featureInstanceId) // Only instance-level roles + .map(r => ({ value: r.id, label: `${r.roleLabel} (${featureInstances.find(i => i.id === r.featureInstanceId)?.label || r.featureCode || ''})` })); const fields = backendAttributes .filter(attr => !excludedFields.includes(attr.name)) .map(attr => ({ ...attr, - // Override roleIds options with dynamic data - options: attr.name === 'roleIds' ? roleOptions : attr.options, + // Override options with dynamic data + options: attr.name === 'roleIds' ? roleOptions + : attr.name === 'featureInstanceId' ? instanceOptions + : attr.options, })) as AttributeDefinition[]; // Add helper field expiresInHours if not in model but fields exist @@ -180,7 +195,7 @@ export const AdminInvitationsPage: React.FC = () => { required: true, default: 72 } as any); } return fields; - }, [roles, backendAttributes]); + }, [roles, featureInstances, backendAttributes]); // Handle create invitation const handleCreateInvitation = async (data: InvitationCreate) => { diff --git a/src/pages/admin/AdminMandateRolePermissionsPage.tsx b/src/pages/admin/AdminMandateRolePermissionsPage.tsx index 5d1bfff..400d48d 100644 --- a/src/pages/admin/AdminMandateRolePermissionsPage.tsx +++ b/src/pages/admin/AdminMandateRolePermissionsPage.tsx @@ -47,7 +47,6 @@ interface DuplicateGroup { } interface CleanupResult { - dryRun: boolean; totalRules: number; uniqueSignatures: number; duplicateGroups: number; @@ -56,6 +55,23 @@ interface CleanupResult { details: DuplicateGroup[]; } +interface TemplateFixDetail { + userMandateRoleId: string; + userMandateId: string; + mandateId: string; + templateRoleId: string; + templateRoleLabel: string; + instanceRoleId?: string; + action: string; +} + +interface TemplateFixResult { + totalUserMandateRoles: number; + invalidAssignments: number; + fixedCount: number; + details: TemplateFixDetail[]; +} + export const AdminMandateRolePermissionsPage: React.FC = () => { const { roles, @@ -76,6 +92,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { const [showCleanupModal, setShowCleanupModal] = useState(false); const [cleanupLoading, setCleanupLoading] = useState(false); const [cleanupResult, setCleanupResult] = useState(null); + const [templateFixResult, setTemplateFixResult] = useState(null); const [cleanupError, setCleanupError] = useState(null); const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle'); @@ -150,11 +167,14 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { setShowCleanupModal(true); setCleanupError(null); setCleanupResult(null); + setTemplateFixResult(null); setCleanupPhase('idle'); setCleanupLoading(true); try { const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=true'); - setCleanupResult(response.data); + const data = response.data; + setCleanupResult(data.duplicateRules || data); + setTemplateFixResult(data.templateRoleAssignments || null); setCleanupPhase('preview'); } catch (err: any) { setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Duplikate'); @@ -168,7 +188,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { setCleanupError(null); try { const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=false'); - setCleanupResult(response.data); + const data = response.data; + setCleanupResult(data.duplicateRules || data); + setTemplateFixResult(data.templateRoleAssignments || null); setCleanupPhase('done'); // Refresh roles after cleanup if (selectedMandateId) { @@ -432,7 +454,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { )} {/* Details Table */} - {cleanupResult.details.length > 0 && ( + {cleanupResult.details && cleanupResult.details.length > 0 && (

Duplikat-Details {cleanupResult.details.length < cleanupResult.duplicateGroups && `(${cleanupResult.details.length} von ${cleanupResult.duplicateGroups})`} @@ -469,6 +491,81 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {

)} + + {/* Template Role Assignments Section */} + {templateFixResult && templateFixResult.invalidAssignments > 0 && ( +
+

+ Template-Rollen-Zuweisungen +

+
+
+
{templateFixResult.totalUserMandateRoles}
+
Rollen-Zuweisungen total
+
+
0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${templateFixResult.invalidAssignments > 0 ? '#fc8181' : '#9ae6b4'}` }}> +
0 ? '#c53030' : '#2f855a' }}>{templateFixResult.invalidAssignments}
+
Template statt Instanz
+
+
+
0 ? '#2f855a' : 'var(--text-primary)' }}> + {cleanupPhase === 'done' ? templateFixResult.fixedCount : templateFixResult.invalidAssignments} +
+
+ {cleanupPhase === 'done' ? 'Repariert' : 'Zu reparieren'} +
+
+
+ + {templateFixResult.details && templateFixResult.details.length > 0 && ( +
+ + + + + + + + + + {templateFixResult.details.map((detail, idx) => ( + + + + + + ))} + +
RolleMandantAktion
+ + {detail.templateRoleLabel} + + + + {detail.mandateId.substring(0, 8)}... + + + + {detail.action} + +
+
+ )} +
+ )} + + {templateFixResult && templateFixResult.invalidAssignments === 0 && ( +
+ + Keine fehlerhaften Template-Rollen-Zuweisungen. +
+ )} )}
@@ -477,13 +574,13 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { - {cleanupPhase === 'preview' && cleanupResult && cleanupResult.duplicateRulesToDelete > 0 && ( + {cleanupPhase === 'preview' && cleanupResult && (cleanupResult.duplicateRulesToDelete > 0 || (templateFixResult && templateFixResult.invalidAssignments > 0)) && ( )} diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index 27fb4ae..8e5bcc1 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -14,4 +14,5 @@ export { AdminMandateRolesPage } from './AdminMandateRolesPage'; export { AdminFeatureRolesPage } from './AdminFeatureRolesPage'; export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage'; export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage'; -export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage'; \ No newline at end of file +export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage'; +export { AdminAutomationEventsPage } from './AdminAutomationEventsPage'; \ No newline at end of file diff --git a/src/pages/workflows/AutomationTemplatesPage.tsx b/src/pages/workflows/AutomationTemplatesPage.tsx index ec509d5..0e5124b 100644 --- a/src/pages/workflows/AutomationTemplatesPage.tsx +++ b/src/pages/workflows/AutomationTemplatesPage.tsx @@ -2,15 +2,17 @@ * AutomationTemplatesPage * * Page for managing automation templates (CRUD). - * Uses FormGeneratorTable for listing and AutomationEditor for editing. + * System templates (isSystem=true) are read-only for non-SysAdmin, with duplicate option. + * Instance templates can be managed by instance admins/editors. */ import React, { useState, useMemo, useEffect } from 'react'; import { useAutomationTemplates, type AutomationTemplate } from '../../hooks/useAutomations'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { AutomationEditor } from '../../components/AutomationEditor'; -import { FaSync, FaPlus, FaFileAlt } from 'react-icons/fa'; +import { FaSync, FaPlus, FaFileAlt, FaCopy, FaLock } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; +import { useCurrentUser } from '../../hooks/useUsers'; import styles from '../admin/Admin.module.css'; export const AutomationTemplatesPage: React.FC = () => { @@ -24,8 +26,11 @@ export const AutomationTemplatesPage: React.FC = () => { createTemplate, updateTemplate, deleteTemplate, + duplicateTemplate, getTemplate, } = useAutomationTemplates(); + const { user: currentUser } = useCurrentUser(); + const isSysAdmin = currentUser?.isSysAdmin || false; const { showSuccess, showError } = useToast(); @@ -48,6 +53,10 @@ export const AutomationTemplatesPage: React.FC = () => { const columns = useMemo(() => [ { key: 'label', label: 'Label', type: 'string' as const, sortable: true, searchable: true, width: 200 }, { key: 'overview', label: 'Beschreibung', type: 'string' as const, width: 300 }, + { key: 'isSystem', label: 'Typ', type: 'custom' as const, width: 100, render: (value: boolean) => + value ? System + : Instanz + }, { key: '_createdByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 }, ], []); @@ -104,6 +113,28 @@ export const AutomationTemplatesPage: React.FC = () => { } }; + // Handle duplicate + const handleDuplicate = async (template: AutomationTemplate) => { + try { + await duplicateTemplate(template.id); + showSuccess('Vorlage dupliziert'); + await refetch(); + } catch (err: any) { + showError(`Fehler beim Duplizieren: ${err.message}`); + } + }; + + // Check if template is editable (system templates only by SysAdmin) + const _canEditTemplate = (template: AutomationTemplate) => { + if ((template as any).isSystem) return isSysAdmin; + return canUpdate; + }; + + const _canDeleteTemplate = (template: AutomationTemplate) => { + if ((template as any).isSystem) return isSysAdmin; + return canDelete; + }; + if (error) { return (
@@ -179,15 +210,31 @@ export const AutomationTemplatesPage: React.FC = () => { sortable={true} selectable={false} actionButtons={[ - ...(canUpdate ? [{ + { + type: 'custom' as const, + icon: , + title: 'Duplizieren', + onAction: handleDuplicate, + }, + { type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten', - }] : []), - ...(canDelete ? [{ + disabled: (row: any) => row.isSystem && !isSysAdmin + ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin bearbeitet werden' } + : !canUpdate + ? { disabled: true, message: 'Keine Berechtigung' } + : false, + }, + { type: 'delete' as const, title: 'Löschen', - }] : []), + disabled: (row: any) => row.isSystem && !isSysAdmin + ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin gelöscht werden' } + : !canDelete + ? { disabled: true, message: 'Keine Berechtigung' } + : false, + }, ]} onDelete={(template) => handleDelete(template.id)} hookData={{ diff --git a/src/pages/workflows/AutomationsPage.tsx b/src/pages/workflows/AutomationsPage.tsx index a28bfe5..f1e1bd1 100644 --- a/src/pages/workflows/AutomationsPage.tsx +++ b/src/pages/workflows/AutomationsPage.tsx @@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react' import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { AutomationEditor } from '../../components/AutomationEditor'; -import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa'; +import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner, FaCopy } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { useApiRequest } from '../../hooks/useApi'; import { useFeatureStore } from '../../stores/featureStore'; @@ -244,6 +244,17 @@ export const AutomationsPage: React.FC = () => { } }; + // Handle duplicate automation + const handleDuplicate = async (automation: Automation) => { + try { + await request('POST', `/api/automations/${automation.id}/duplicate`); + showSuccess('Automatisierung dupliziert'); + await refetch(); + } catch (err: any) { + showError(`Fehler beim Duplizieren: ${err.message}`); + } + }; + // Load templates const handleLoadTemplates = async () => { setLoadingTemplates(true); @@ -322,10 +333,15 @@ export const AutomationsPage: React.FC = () => { // Poll workflow logs const pollWorkflowLogs = useCallback(async (workflowId: string) => { try { + // Include mandate context header for RBAC (featureInstanceId is not needed for workflows) + const contextHeaders: Record = {}; + if (mandateId) contextHeaders['X-Mandate-Id'] = mandateId; + const response = await request({ url: `/api/workflows/${workflowId}/logs`, method: 'get', params: lastLogIdRef.current ? { afterId: lastLogIdRef.current } : {}, + headers: contextHeaders, }); const logs: WorkflowLog[] = response?.items || response || []; @@ -348,6 +364,7 @@ export const AutomationsPage: React.FC = () => { const statusResponse = await request({ url: `/api/workflows/${workflowId}`, method: 'get', + headers: contextHeaders, }); const workflowStatus = statusResponse?.status; @@ -378,7 +395,7 @@ export const AutomationsPage: React.FC = () => { } catch (err) { console.error('Error polling workflow logs:', err); } - }, [request, refetch, showSuccess, showError, showInfo]); + }, [request, refetch, showSuccess, showError, showInfo, mandateId, featureInstanceId]); // Handle execute automation with modal const handleExecute = async (automation: Automation) => { @@ -439,9 +456,13 @@ export const AutomationsPage: React.FC = () => { if (!executionModal.workflowId) return; try { + const stopHeaders: Record = {}; + if (mandateId) stopHeaders['X-Mandate-Id'] = mandateId; + await request({ url: `/api/workflows/${executionModal.workflowId}/stop`, method: 'post', + headers: stopHeaders, }); setExecutionModal(prev => ({ @@ -606,6 +627,12 @@ export const AutomationsPage: React.FC = () => { sortable={true} selectable={false} actionButtons={[ + ...(canCreate ? [{ + type: 'custom' as const, + icon: , + title: 'Duplizieren', + onAction: handleDuplicate, + }] : []), ...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, @@ -758,7 +785,7 @@ export const AutomationsPage: React.FC = () => { style={{ maxHeight: '400px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.875rem' }} > {executionModal.logs.map((log, index) => ( -
+
[{formatTime(log.timestamp)}] {log.status && {log.status}:} {log.message}