@@ -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 && (
+
+
+
+
+ | Rolle |
+ Mandant |
+ Aktion |
+
+
+
+ {templateFixResult.details.map((detail, idx) => (
+
+ |
+
+ {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 = () => {
@@ -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}