fixed rbac issues and sysadmin integration

This commit is contained in:
ValueOn AG 2026-02-12 00:34:25 +01:00
parent 7798463e24
commit 9312e76737
17 changed files with 538 additions and 109 deletions

View file

@ -41,7 +41,7 @@ import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings'; import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR'; import { GDPRPage } from './pages/GDPR';
import { FeatureViewPage } from './pages/FeatureView'; 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) // Basedata Pages (global)
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
@ -178,6 +178,7 @@ function App() {
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} /> <Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} /> <Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing" element={<BillingAdmin />} /> <Route path="billing" element={<BillingAdmin />} />
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
</Route> </Route>
</Route> </Route>

View file

@ -21,8 +21,12 @@ const resolveHostnameToIP = async (hostname: string): Promise<string | null> =>
}; };
/** /**
* Extract mandate/instance context from current URL * Extract mandate/instance context from current URL.
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/... * 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 getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
const pathname = window.location.pathname; const pathname = window.location.pathname;

View file

@ -128,7 +128,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string):
return ( return (
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height={260} minWidth={0}>
<BarChart data={chartData} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}> <BarChart data={chartData} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis <XAxis
@ -197,7 +197,7 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
return ( return (
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height={260} minWidth={0}>
<LineChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}> <LineChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis <XAxis
@ -243,7 +243,7 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
return ( return (
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height={260} minWidth={0}>
<AreaChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}> <AreaChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis <XAxis
@ -301,7 +301,7 @@ const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string):
return ( return (
<div className={styles.chartWrapperSmall}> <div className={styles.chartWrapperSmall}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height={230} minWidth={0}>
<PieChart margin={{ top: 20, right: 10, left: 10, bottom: 5 }}> <PieChart margin={{ top: 20, right: 10, left: 10, bottom: 5 }}>
<Pie <Pie
data={chartData} data={chartData}

View file

@ -16,11 +16,11 @@
import React from 'react'; import React from 'react';
import { import {
FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag, FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt, FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone, FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase, FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock
} from 'react-icons/fa'; } from 'react-icons/fa';
// ============================================================================= // =============================================================================
@ -36,35 +36,36 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.system.home': <FaHome />, 'page.system.home': <FaHome />,
'page.system.settings': <FaCog />, 'page.system.settings': <FaCog />,
'page.system.gdpr': <FaShieldAlt />, 'page.system.gdpr': <FaShieldAlt />,
'page.system.playground': <FaPlay />,
'page.system.chats': <FaListAlt />, // Basedata pages (system-level)
'page.system.automations': <FaCogs />,
'page.system.automation-templates': <FaFileAlt />,
'page.system.prompts': <FaLightbulb />, 'page.system.prompts': <FaLightbulb />,
'page.system.files': <FaRegFileAlt />, 'page.system.files': <FaRegFileAlt />,
'page.system.connections': <FaLink />, 'page.system.connections': <FaLink />,
'page.system.chatbot': <FaComments />,
'page.system.pek': <FaChartBar />,
'page.system.speech': <FaMicrophone />,
// Billing pages // Billing pages
'page.billing.dashboard': <FaWallet />, 'page.billing.dashboard': <FaWallet />,
'page.billing.transactions': <FaListAlt />, 'page.billing.transactions': <FaListAlt />,
// Admin pages // Admin pages (kebab-case + camelCase variants for backend compatibility)
'page.admin.access': <FaBuilding />, 'page.admin.access': <FaBuilding />,
'page.admin.users': <FaUsers />, 'page.admin.users': <FaUsers />,
'page.admin.invitations': <FaEnvelopeOpenText />, 'page.admin.invitations': <FaEnvelopeOpenText />,
'page.admin.mandates': <FaBuilding />, 'page.admin.mandates': <FaBuilding />,
'page.admin.roles': <FaKey />, 'page.admin.roles': <FaKey />,
'page.admin.role-permissions': <FaShieldAlt />, 'page.admin.role-permissions': <FaShieldAlt />,
'page.admin.mandateRolePermissions': <FaShieldAlt />,
'page.admin.user-mandates': <FaUserTag />, 'page.admin.user-mandates': <FaUserTag />,
'page.admin.userMandates': <FaUserTag />,
'page.admin.feature-roles': <FaCube />, 'page.admin.feature-roles': <FaCube />,
'page.admin.featureRoles': <FaCube />,
'page.admin.feature-instances': <FaCubes />, 'page.admin.feature-instances': <FaCubes />,
'page.admin.featureInstances': <FaCubes />, 'page.admin.featureInstances': <FaCubes />,
'page.admin.feature-users': <FaUsersCog />, 'page.admin.feature-users': <FaUsersCog />,
'page.admin.user-access-overview': <FaUserShield />, 'page.admin.user-access-overview': <FaUserShield />,
'page.admin.userAccessOverview': <FaUserShield />,
'page.admin.billing': <FaMoneyBillAlt />, 'page.admin.billing': <FaMoneyBillAlt />,
'page.admin.automationEvents': <FaClock />,
'page.admin.automation-events': <FaClock />,
// Feature pages - Trustee // Feature pages - Trustee
'page.feature.trustee.dashboard': <FaChartLine />, 'page.feature.trustee.dashboard': <FaChartLine />,
@ -82,7 +83,9 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.trustee': <FaBriefcase />, 'feature.trustee': <FaBriefcase />,
'feature.realestate': <FaBuilding />, 'feature.realestate': <FaBuilding />,
'feature.chatworkflow': <FaPlay />, 'feature.chatworkflow': <FaPlay />,
'feature.chatbot': <FaRobot />, 'feature.chatplayground': <FaPlay />,
'feature.automation': <FaCogs />,
'feature.chatbot': <FaComments />,
}; };
// ============================================================================= // =============================================================================
@ -91,10 +94,13 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
/** /**
* Get icon for a uiComponent code. * 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 { export function getPageIcon(uiComponent: string): React.ReactNode {
return PAGE_ICONS[uiComponent] || <FaCog />; if (!PAGE_ICONS[uiComponent]) {
console.warn(`[pageRegistry] Missing icon for uiComponent: "${uiComponent}"`);
}
return PAGE_ICONS[uiComponent] || null;
} }
/** /**

View file

@ -32,8 +32,8 @@ export function useDashboardInputForm(instanceId: string) {
const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null); const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); // AI provider selection (multiselect) const [selectedProviders, setSelectedProviders] = useState<string[]>([]); // AI provider selection (multiselect)
const { checkPermission, canView } = usePermissions(); const { checkPermission } = usePermissions();
const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(false); const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(true);
const [chatWorkflowPermission, setChatWorkflowPermission] = useState<any>(null); const [chatWorkflowPermission, setChatWorkflowPermission] = useState<any>(null);
const [promptPermission, setPromptPermission] = useState<any>(null); const [promptPermission, setPromptPermission] = useState<any>(null);
const [filePermission, setFilePermission] = useState<any>(null); const [filePermission, setFilePermission] = useState<any>(null);
@ -84,23 +84,23 @@ export function useDashboardInputForm(instanceId: string) {
useEffect(() => { useEffect(() => {
const checkPermissions = async () => { const checkPermissions = async () => {
try { try {
const uiPerm = await canView('UI', 'ui.system.playground'); // UI permission is already verified by the navigation/routing layer
setPlaygroundUIPermission(uiPerm); // (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');
const chatWorkflowPerm = await checkPermission('DATA', 'ChatWorkflow'); setChatWorkflowPermission(chatWorkflowPerm);
setChatWorkflowPermission(chatWorkflowPerm); const promptPerm = await checkPermission('DATA', 'Prompt');
const promptPerm = await checkPermission('DATA', 'Prompt'); setPromptPermission(promptPerm);
setPromptPermission(promptPerm); const filePerm = await checkPermission('DATA', 'FileItem');
const filePerm = await checkPermission('DATA', 'FileItem'); setFilePermission(filePerm);
setFilePermission(filePerm);
}
} catch (error) { } catch (error) {
} }
}; };
checkPermissions(); checkPermissions();
}, [canView, checkPermission]); }, [checkPermission]);
// Sync context -> lifecycle: When context selection changes, update lifecycle // Sync context -> lifecycle: When context selection changes, update lifecycle
useEffect(() => { useEffect(() => {
@ -609,15 +609,16 @@ export function useDashboardInputForm(instanceId: string) {
setOptimisticMessage(null); setOptimisticMessage(null);
// Reset workflow lifecycle state // Reset workflow lifecycle state
resetWorkflow(); resetWorkflow();
// Clear context selection // NOTE: Do NOT call clearWorkflowFromContext() here — this handler is
clearWorkflowFromContext(); // triggered BY clearWorkflow() which already set the context to null.
// Calling it again would dispatch another 'workflowCleared' event → infinite recursion.
}; };
window.addEventListener('workflowCleared', handleWorkflowCleared); window.addEventListener('workflowCleared', handleWorkflowCleared);
return () => { return () => {
window.removeEventListener('workflowCleared', handleWorkflowCleared); window.removeEventListener('workflowCleared', handleWorkflowCleared);
}; };
}, [resetWorkflow, clearWorkflowFromContext]); }, [resetWorkflow]);
const handleWorkflowSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => { const handleWorkflowSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
if (item === null) { if (item === null) {

View file

@ -540,6 +540,11 @@ export function useAutomationTemplates() {
await deleteAutomationTemplateApi(request, templateId); await deleteAutomationTemplateApi(request, templateId);
}, [request]); }, [request]);
const duplicateTemplate = useCallback(async (templateId: string) => {
const response = await request('POST', `/api/automation-templates/${templateId}/duplicate`);
return response;
}, [request]);
const refetch = useCallback(async () => { const refetch = useCallback(async () => {
await Promise.all([ await Promise.all([
fetchTemplates(), fetchTemplates(),
@ -563,6 +568,7 @@ export function useAutomationTemplates() {
createTemplate, createTemplate,
updateTemplate, updateTemplate,
deleteTemplate, deleteTemplate,
duplicateTemplate,
}; };
} }

View file

@ -2,7 +2,7 @@
* Dashboard Page * Dashboard Page
* *
* System-Übersicht für den User. * 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. * Daten kommen vom Backend via GET /api/navigation.
*/ */
@ -11,7 +11,7 @@ import { Link } from 'react-router-dom';
import useNavigation from '../hooks/useNavigation'; import useNavigation from '../hooks/useNavigation';
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
import { getPageIcon } from '../config/pageRegistry'; import { getPageIcon } from '../config/pageRegistry';
import { FaArrowRight } from 'react-icons/fa'; import { FaArrowRight, FaBuilding } from 'react-icons/fa';
import styles from './Dashboard.module.css'; import styles from './Dashboard.module.css';
// ============================================================================= // =============================================================================
@ -21,10 +21,9 @@ import styles from './Dashboard.module.css';
interface InstanceCardProps { interface InstanceCardProps {
instance: NavFeatureInstance; instance: NavFeatureInstance;
feature: MandateFeature; feature: MandateFeature;
mandateLabel: string;
} }
const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature, mandateLabel }) => { const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
// Ersten verfügbaren View-Pfad vom Backend nehmen // Ersten verfügbaren View-Pfad vom Backend nehmen
const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined; const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined;
@ -40,7 +39,6 @@ const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature, mandateL
<span className={styles.featureLabel}>{feature.uiLabel}</span> <span className={styles.featureLabel}>{feature.uiLabel}</span>
</div> </div>
<h3 className={styles.instanceLabel}>{instance.uiLabel}</h3> <h3 className={styles.instanceLabel}>{instance.uiLabel}</h3>
<p className={styles.mandateName}>{mandateLabel}</p>
</div> </div>
<div className={styles.cardArrow}> <div className={styles.cardArrow}>
<FaArrowRight /> <FaArrowRight />
@ -94,25 +92,6 @@ export const DashboardPage: React.FC = () => {
return <EmptyState />; return <EmptyState />;
} }
// Gruppiere Instanzen nach Feature (über alle Mandate)
const featureGroups: { feature: MandateFeature; instances: { instance: NavFeatureInstance; mandateLabel: string }[] }[] = [];
const featureMap = new Map<string, typeof featureGroups[0]>();
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 ( return (
<div className={styles.dashboard}> <div className={styles.dashboard}>
<header className={styles.header}> <header className={styles.header}>
@ -123,24 +102,35 @@ export const DashboardPage: React.FC = () => {
</header> </header>
<main className={styles.content}> <main className={styles.content}>
{featureGroups.map(({ feature, instances }) => ( {mandates
<section key={feature.uiComponent} className={styles.featureSection}> .filter(mandate => mandate.features.some(f => f.instances.length > 0))
<h2 className={styles.sectionTitle}> .map(mandate => {
{getPageIcon(feature.uiComponent)} // Alle Instanzen dieses Mandats sammeln (flach, ohne Feature-Gruppierung)
<span>{feature.uiLabel}</span> const mandateInstances: { instance: NavFeatureInstance; feature: MandateFeature }[] = [];
</h2> for (const feature of mandate.features) {
<div className={styles.instanceGrid}> for (const instance of feature.instances) {
{instances.map(({ instance, mandateLabel }) => ( mandateInstances.push({ instance, feature });
<InstanceCard }
key={instance.id} }
instance={instance}
feature={feature} return (
mandateLabel={mandateLabel} <section key={mandate.id} className={styles.featureSection}>
/> <h2 className={styles.sectionTitle}>
))} <FaBuilding />
</div> <span>{mandate.uiLabel}</span>
</section> </h2>
))} <div className={styles.instanceGrid}>
{mandateInstances.map(({ instance, feature }) => (
<InstanceCard
key={instance.id}
instance={instance}
feature={feature}
/>
))}
</div>
</section>
);
})}
</main> </main>
</div> </div>
); );

View file

@ -23,7 +23,7 @@
margin: 0; margin: 0;
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary, #1a1a1a); color: var(--text-primary);
} }
.titleIcon { .titleIcon {
@ -60,7 +60,7 @@
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary, #1a1a1a); color: var(--text-primary);
} }
.actions { .actions {
@ -82,7 +82,7 @@
.actionCard h3 { .actionCard h3 {
margin: 0; margin: 0;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--text-primary, #1a1a1a); color: var(--text-primary);
} }
.actionCard p { .actionCard p {
@ -145,7 +145,7 @@
.secondaryButton { .secondaryButton {
background: var(--surface-color, #f5f5f5); background: var(--surface-color, #f5f5f5);
color: var(--text-primary, #1a1a1a); color: var(--text-primary);
border-color: var(--border-color, #d0d0d0); border-color: var(--border-color, #d0d0d0);
} }
@ -214,6 +214,7 @@
.infoBlock h3 { .infoBlock h3 {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-primary);
} }
.infoBlock ul { .infoBlock ul {

View file

@ -427,6 +427,8 @@
:global(.dark-theme) .modalContent { :global(.dark-theme) .modalContent {
background: var(--bg-dark, #111827); 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 { :global(.dark-theme) .modalHeader {

View file

@ -295,7 +295,10 @@ export const SettingsPage: React.FC = () => {
<div className={styles.settingControl}> <div className={styles.settingControl}>
<button <button
className={styles.button} className={styles.button}
onClick={() => setIsProfileModalOpen(true)} onClick={async () => {
await refetchUser();
setIsProfileModalOpen(true);
}}
> >
Profil öffnen Profil öffnen
</button> </button>

View file

@ -549,7 +549,12 @@
} }
.logStatus { .logStatus {
color: var(--primary-color, #f25843); color: #1976d2;
}
.logEntryError .logStatus,
.logEntryError .logMessage {
color: #d32f2f;
} }
.logMessage { .logMessage {

View file

@ -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<AutomationEvent[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [syncResult, setSyncResult] = useState<string | null>(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 <span style={{ color: 'var(--text-tertiary, #999)' }}></span>;
return formatted;
},
},
{
key: 'trigger',
label: 'Trigger',
type: 'string' as const,
sortable: false,
width: 160,
minWidth: 100,
},
], []);
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Automation Events</h1>
<p className={styles.pageSubtitle}>
Aktive Scheduler-Jobs ({events.length} Events)
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={_fetchEvents}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={_handleSync}
disabled={syncing}
>
<FaSync className={syncing ? 'spinning' : ''} /> Sync All
</button>
</div>
</div>
{syncResult && (
<div className={styles.infoBox} style={{ background: 'var(--success-bg, #f0fff4)', borderColor: 'var(--success-color, #38a169)' }}>
<span style={{ marginRight: 8, color: 'var(--success-color, #38a169)' }}>&#10003;</span>
{syncResult}
</div>
)}
{error && (
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
{error}
</div>
)}
<FormGeneratorTable
data={events}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'delete' as const,
title: 'Event entfernen',
},
]}
hookData={{
handleDelete: _handleDelete,
refetch: _fetchEvents,
}}
emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren."
/>
</div>
);
};
export default AdminAutomationEventsPage;

View file

@ -8,6 +8,7 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations'; import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations';
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates'; import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAccess';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaEnvelopeOpenText, FaBuilding, FaCopy, FaLink } from 'react-icons/fa'; import { FaPlus, FaSync, FaEnvelopeOpenText, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
@ -28,10 +29,12 @@ export const AdminInvitationsPage: React.FC = () => {
} = useInvitations(); } = useInvitations();
const { fetchMandates, fetchRoles } = useUserMandates(); const { fetchMandates, fetchRoles } = useUserMandates();
const { fetchInstances } = useFeatureAccess();
// State // State
const [mandates, setMandates] = useState<Mandate[]>([]); const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>(''); const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [featureInstances, setFeatureInstances] = useState<FeatureInstance[]>([]);
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showUrlModal, setShowUrlModal] = useState<Invitation | null>(null); const [showUrlModal, setShowUrlModal] = useState<Invitation | null>(null);
@ -58,16 +61,18 @@ export const AdminInvitationsPage: React.FC = () => {
}).catch(() => setBackendAttributes([])); }).catch(() => setBackendAttributes([]));
}, [fetchMandates]); }, [fetchMandates]);
// Load invitations and roles when mandate changes // Load invitations, feature instances, and roles when mandate changes
useEffect(() => { useEffect(() => {
if (selectedMandateId) { if (selectedMandateId) {
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }); fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
fetchInstances(selectedMandateId).then(instances => {
setFeatureInstances(instances);
});
fetchRoles(selectedMandateId).then(fetchedRoles => { fetchRoles(selectedMandateId).then(fetchedRoles => {
console.warn('[AdminInvitations] fetchRoles result:', { mandateId: selectedMandateId, rolesCount: fetchedRoles.length, roles: fetchedRoles });
setRoles(fetchedRoles); setRoles(fetchedRoles);
}); });
} }
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]); }, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchInstances, fetchRoles]);
// Format timestamp // Format timestamp
const formatDate = (timestamp: number) => { const formatDate = (timestamp: number) => {
@ -159,19 +164,29 @@ export const AdminInvitationsPage: React.FC = () => {
}, },
], [roles]); ], [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 createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl']; 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 const roleOptions = roles
.filter(r => !r.featureInstanceId) // Only mandate-level roles .filter(r => !!r.featureInstanceId) // Only instance-level roles
.map(r => ({ value: r.id, label: r.roleLabel })); .map(r => ({ value: r.id, label: `${r.roleLabel} (${featureInstances.find(i => i.id === r.featureInstanceId)?.label || r.featureCode || ''})` }));
const fields = backendAttributes const fields = backendAttributes
.filter(attr => !excludedFields.includes(attr.name)) .filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({ .map(attr => ({
...attr, ...attr,
// Override roleIds options with dynamic data // Override options with dynamic data
options: attr.name === 'roleIds' ? roleOptions : attr.options, options: attr.name === 'roleIds' ? roleOptions
: attr.name === 'featureInstanceId' ? instanceOptions
: attr.options,
})) as AttributeDefinition[]; })) as AttributeDefinition[];
// Add helper field expiresInHours if not in model but fields exist // 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); required: true, default: 72 } as any);
} }
return fields; return fields;
}, [roles, backendAttributes]); }, [roles, featureInstances, backendAttributes]);
// Handle create invitation // Handle create invitation
const handleCreateInvitation = async (data: InvitationCreate) => { const handleCreateInvitation = async (data: InvitationCreate) => {

View file

@ -47,7 +47,6 @@ interface DuplicateGroup {
} }
interface CleanupResult { interface CleanupResult {
dryRun: boolean;
totalRules: number; totalRules: number;
uniqueSignatures: number; uniqueSignatures: number;
duplicateGroups: number; duplicateGroups: number;
@ -56,6 +55,23 @@ interface CleanupResult {
details: DuplicateGroup[]; 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 = () => { export const AdminMandateRolePermissionsPage: React.FC = () => {
const { const {
roles, roles,
@ -76,6 +92,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
const [showCleanupModal, setShowCleanupModal] = useState(false); const [showCleanupModal, setShowCleanupModal] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false); const [cleanupLoading, setCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null); const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null);
const [templateFixResult, setTemplateFixResult] = useState<TemplateFixResult | null>(null);
const [cleanupError, setCleanupError] = useState<string | null>(null); const [cleanupError, setCleanupError] = useState<string | null>(null);
const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle'); const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle');
@ -150,11 +167,14 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
setShowCleanupModal(true); setShowCleanupModal(true);
setCleanupError(null); setCleanupError(null);
setCleanupResult(null); setCleanupResult(null);
setTemplateFixResult(null);
setCleanupPhase('idle'); setCleanupPhase('idle');
setCleanupLoading(true); setCleanupLoading(true);
try { try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=true'); 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'); setCleanupPhase('preview');
} catch (err: any) { } catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Duplikate'); setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Duplikate');
@ -168,7 +188,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
setCleanupError(null); setCleanupError(null);
try { try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=false'); 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'); setCleanupPhase('done');
// Refresh roles after cleanup // Refresh roles after cleanup
if (selectedMandateId) { if (selectedMandateId) {
@ -432,7 +454,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
)} )}
{/* Details Table */} {/* Details Table */}
{cleanupResult.details.length > 0 && ( {cleanupResult.details && cleanupResult.details.length > 0 && (
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
<h4 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-secondary)' }}> <h4 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-secondary)' }}>
Duplikat-Details {cleanupResult.details.length < cleanupResult.duplicateGroups && `(${cleanupResult.details.length} von ${cleanupResult.duplicateGroups})`} Duplikat-Details {cleanupResult.details.length < cleanupResult.duplicateGroups && `(${cleanupResult.details.length} von ${cleanupResult.duplicateGroups})`}
@ -469,6 +491,81 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Template Role Assignments Section */}
{templateFixResult && templateFixResult.invalidAssignments > 0 && (
<div style={{ marginTop: '1.25rem', borderTop: '1px solid var(--border-color)', paddingTop: '1rem' }}>
<h4 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-primary)' }}>
Template-Rollen-Zuweisungen
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{templateFixResult.totalUserMandateRoles}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Rollen-Zuweisungen total</div>
</div>
<div style={{ padding: '0.75rem', background: templateFixResult.invalidAssignments > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${templateFixResult.invalidAssignments > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.invalidAssignments > 0 ? '#c53030' : '#2f855a' }}>{templateFixResult.invalidAssignments}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Template statt Instanz</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.fixedCount > 0 ? '#2f855a' : 'var(--text-primary)' }}>
{cleanupPhase === 'done' ? templateFixResult.fixedCount : templateFixResult.invalidAssignments}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{cleanupPhase === 'done' ? 'Repariert' : 'Zu reparieren'}
</div>
</div>
</div>
{templateFixResult.details && templateFixResult.details.length > 0 && (
<div style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Rolle</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Mandant</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Aktion</th>
</tr>
</thead>
<tbody>
{templateFixResult.details.map((detail, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{detail.templateRoleLabel}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{detail.mandateId.substring(0, 8)}...
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>
<span style={{
fontSize: '0.75rem',
padding: '0.125rem 0.5rem',
borderRadius: '10px',
background: detail.action.includes('replace') ? '#ebf8ff' : detail.action.includes('delete') ? '#fff5f5' : '#f7fafc',
color: detail.action.includes('replace') ? '#2b6cb0' : detail.action.includes('delete') ? '#c53030' : '#718096',
}}>
{detail.action}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{templateFixResult && templateFixResult.invalidAssignments === 0 && (
<div style={{ marginTop: '1rem', padding: '0.5rem 0.75rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', fontSize: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>Keine fehlerhaften Template-Rollen-Zuweisungen.</span>
</div>
)}
</> </>
)} )}
</div> </div>
@ -477,13 +574,13 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<button className={styles.secondaryButton} onClick={_closeCleanupModal}> <button className={styles.secondaryButton} onClick={_closeCleanupModal}>
{cleanupPhase === 'done' ? 'Schliessen' : 'Abbrechen'} {cleanupPhase === 'done' ? 'Schliessen' : 'Abbrechen'}
</button> </button>
{cleanupPhase === 'preview' && cleanupResult && cleanupResult.duplicateRulesToDelete > 0 && ( {cleanupPhase === 'preview' && cleanupResult && (cleanupResult.duplicateRulesToDelete > 0 || (templateFixResult && templateFixResult.invalidAssignments > 0)) && (
<button <button
className={styles.dangerButton} className={styles.dangerButton}
onClick={_executeCleanup} onClick={_executeCleanup}
disabled={cleanupLoading} disabled={cleanupLoading}
> >
<FaBroom /> {cleanupResult.duplicateRulesToDelete} Duplikate loeschen <FaBroom /> Bereinigung ausfuehren
</button> </button>
)} )}
</div> </div>

View file

@ -14,4 +14,5 @@ export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage'; export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage'; export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage'; export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage'; export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';

View file

@ -2,15 +2,17 @@
* AutomationTemplatesPage * AutomationTemplatesPage
* *
* Page for managing automation templates (CRUD). * 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 React, { useState, useMemo, useEffect } from 'react';
import { useAutomationTemplates, type AutomationTemplate } from '../../hooks/useAutomations'; import { useAutomationTemplates, type AutomationTemplate } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { AutomationEditor } from '../../components/AutomationEditor'; 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 { useToast } from '../../contexts/ToastContext';
import { useCurrentUser } from '../../hooks/useUsers';
import styles from '../admin/Admin.module.css'; import styles from '../admin/Admin.module.css';
export const AutomationTemplatesPage: React.FC = () => { export const AutomationTemplatesPage: React.FC = () => {
@ -24,8 +26,11 @@ export const AutomationTemplatesPage: React.FC = () => {
createTemplate, createTemplate,
updateTemplate, updateTemplate,
deleteTemplate, deleteTemplate,
duplicateTemplate,
getTemplate, getTemplate,
} = useAutomationTemplates(); } = useAutomationTemplates();
const { user: currentUser } = useCurrentUser();
const isSysAdmin = currentUser?.isSysAdmin || false;
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
@ -48,6 +53,10 @@ export const AutomationTemplatesPage: React.FC = () => {
const columns = useMemo(() => [ const columns = useMemo(() => [
{ key: 'label', label: 'Label', type: 'string' as const, sortable: true, searchable: true, width: 200 }, { 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: 'overview', label: 'Beschreibung', type: 'string' as const, width: 300 },
{ key: 'isSystem', label: 'Typ', type: 'custom' as const, width: 100, render: (value: boolean) =>
value ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--info-color, #3182ce)', color: '#fff' }}><FaLock style={{ fontSize: '0.625rem' }} /> System</span>
: <span style={{ fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--success-color, #38a169)', color: '#fff' }}>Instanz</span>
},
{ key: '_createdByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 }, { 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) { if (error) {
return ( return (
<div className={styles.adminPage}> <div className={styles.adminPage}>
@ -179,15 +210,31 @@ export const AutomationTemplatesPage: React.FC = () => {
sortable={true} sortable={true}
selectable={false} selectable={false}
actionButtons={[ actionButtons={[
...(canUpdate ? [{ {
type: 'custom' as const,
icon: <FaCopy />,
title: 'Duplizieren',
onAction: handleDuplicate,
},
{
type: 'edit' as const, type: 'edit' as const,
onAction: handleEditClick, onAction: handleEditClick,
title: 'Bearbeiten', title: 'Bearbeiten',
}] : []), disabled: (row: any) => row.isSystem && !isSysAdmin
...(canDelete ? [{ ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin bearbeitet werden' }
: !canUpdate
? { disabled: true, message: 'Keine Berechtigung' }
: false,
},
{
type: 'delete' as const, type: 'delete' as const,
title: 'Löschen', 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)} onDelete={(template) => handleDelete(template.id)}
hookData={{ hookData={{

View file

@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations'; import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { AutomationEditor } from '../../components/AutomationEditor'; 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 { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
import { useFeatureStore } from '../../stores/featureStore'; 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 // Load templates
const handleLoadTemplates = async () => { const handleLoadTemplates = async () => {
setLoadingTemplates(true); setLoadingTemplates(true);
@ -322,10 +333,15 @@ export const AutomationsPage: React.FC = () => {
// Poll workflow logs // Poll workflow logs
const pollWorkflowLogs = useCallback(async (workflowId: string) => { const pollWorkflowLogs = useCallback(async (workflowId: string) => {
try { try {
// Include mandate context header for RBAC (featureInstanceId is not needed for workflows)
const contextHeaders: Record<string, string> = {};
if (mandateId) contextHeaders['X-Mandate-Id'] = mandateId;
const response = await request({ const response = await request({
url: `/api/workflows/${workflowId}/logs`, url: `/api/workflows/${workflowId}/logs`,
method: 'get', method: 'get',
params: lastLogIdRef.current ? { afterId: lastLogIdRef.current } : {}, params: lastLogIdRef.current ? { afterId: lastLogIdRef.current } : {},
headers: contextHeaders,
}); });
const logs: WorkflowLog[] = response?.items || response || []; const logs: WorkflowLog[] = response?.items || response || [];
@ -348,6 +364,7 @@ export const AutomationsPage: React.FC = () => {
const statusResponse = await request({ const statusResponse = await request({
url: `/api/workflows/${workflowId}`, url: `/api/workflows/${workflowId}`,
method: 'get', method: 'get',
headers: contextHeaders,
}); });
const workflowStatus = statusResponse?.status; const workflowStatus = statusResponse?.status;
@ -378,7 +395,7 @@ export const AutomationsPage: React.FC = () => {
} catch (err) { } catch (err) {
console.error('Error polling workflow logs:', 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 // Handle execute automation with modal
const handleExecute = async (automation: Automation) => { const handleExecute = async (automation: Automation) => {
@ -439,9 +456,13 @@ export const AutomationsPage: React.FC = () => {
if (!executionModal.workflowId) return; if (!executionModal.workflowId) return;
try { try {
const stopHeaders: Record<string, string> = {};
if (mandateId) stopHeaders['X-Mandate-Id'] = mandateId;
await request({ await request({
url: `/api/workflows/${executionModal.workflowId}/stop`, url: `/api/workflows/${executionModal.workflowId}/stop`,
method: 'post', method: 'post',
headers: stopHeaders,
}); });
setExecutionModal(prev => ({ setExecutionModal(prev => ({
@ -606,6 +627,12 @@ export const AutomationsPage: React.FC = () => {
sortable={true} sortable={true}
selectable={false} selectable={false}
actionButtons={[ actionButtons={[
...(canCreate ? [{
type: 'custom' as const,
icon: <FaCopy />,
title: 'Duplizieren',
onAction: handleDuplicate,
}] : []),
...(canUpdate ? [{ ...(canUpdate ? [{
type: 'edit' as const, type: 'edit' as const,
onAction: handleEditClick, onAction: handleEditClick,
@ -758,7 +785,7 @@ export const AutomationsPage: React.FC = () => {
style={{ maxHeight: '400px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.875rem' }} style={{ maxHeight: '400px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.875rem' }}
> >
{executionModal.logs.map((log, index) => ( {executionModal.logs.map((log, index) => (
<div key={log.id || index} className={styles.logEntry}> <div key={log.id || index} className={`${styles.logEntry} ${log.status === 'error' || log.status === 'failed' ? styles.logEntryError : ''}`}>
<span className={styles.logTime}>[{formatTime(log.timestamp)}]</span> <span className={styles.logTime}>[{formatTime(log.timestamp)}]</span>
{log.status && <span className={styles.logStatus}><strong>{log.status}:</strong></span>} {log.status && <span className={styles.logStatus}><strong>{log.status}:</strong></span>}
<span className={styles.logMessage}>{log.message}</span> <span className={styles.logMessage}>{log.message}</span>