fixed rbac issues and sysadmin integration
This commit is contained in:
parent
7798463e24
commit
9312e76737
17 changed files with 538 additions and 109 deletions
|
|
@ -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() {
|
|||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||
<Route path="billing" element={<BillingAdmin />} />
|
||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -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/...
|
||||
*
|
||||
* 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;
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string):
|
|||
|
||||
return (
|
||||
<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 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
|
|
@ -197,7 +197,7 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
|
|||
|
||||
return (
|
||||
<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 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
|
|
@ -243,7 +243,7 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
|
|||
|
||||
return (
|
||||
<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 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
|
|
@ -301,7 +301,7 @@ const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string):
|
|||
|
||||
return (
|
||||
<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 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@
|
|||
|
||||
import React from 'react';
|
||||
import {
|
||||
FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
||||
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
||||
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
|
||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
||||
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt
|
||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock
|
||||
} from 'react-icons/fa';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -36,35 +36,36 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.system.home': <FaHome />,
|
||||
'page.system.settings': <FaCog />,
|
||||
'page.system.gdpr': <FaShieldAlt />,
|
||||
'page.system.playground': <FaPlay />,
|
||||
'page.system.chats': <FaListAlt />,
|
||||
'page.system.automations': <FaCogs />,
|
||||
'page.system.automation-templates': <FaFileAlt />,
|
||||
|
||||
// Basedata pages (system-level)
|
||||
'page.system.prompts': <FaLightbulb />,
|
||||
'page.system.files': <FaRegFileAlt />,
|
||||
'page.system.connections': <FaLink />,
|
||||
'page.system.chatbot': <FaComments />,
|
||||
'page.system.pek': <FaChartBar />,
|
||||
'page.system.speech': <FaMicrophone />,
|
||||
|
||||
// Billing pages
|
||||
'page.billing.dashboard': <FaWallet />,
|
||||
'page.billing.transactions': <FaListAlt />,
|
||||
|
||||
// Admin pages
|
||||
// Admin pages (kebab-case + camelCase variants for backend compatibility)
|
||||
'page.admin.access': <FaBuilding />,
|
||||
'page.admin.users': <FaUsers />,
|
||||
'page.admin.invitations': <FaEnvelopeOpenText />,
|
||||
'page.admin.mandates': <FaBuilding />,
|
||||
'page.admin.roles': <FaKey />,
|
||||
'page.admin.role-permissions': <FaShieldAlt />,
|
||||
'page.admin.mandateRolePermissions': <FaShieldAlt />,
|
||||
'page.admin.user-mandates': <FaUserTag />,
|
||||
'page.admin.userMandates': <FaUserTag />,
|
||||
'page.admin.feature-roles': <FaCube />,
|
||||
'page.admin.featureRoles': <FaCube />,
|
||||
'page.admin.feature-instances': <FaCubes />,
|
||||
'page.admin.featureInstances': <FaCubes />,
|
||||
'page.admin.feature-users': <FaUsersCog />,
|
||||
'page.admin.user-access-overview': <FaUserShield />,
|
||||
'page.admin.userAccessOverview': <FaUserShield />,
|
||||
'page.admin.billing': <FaMoneyBillAlt />,
|
||||
'page.admin.automationEvents': <FaClock />,
|
||||
'page.admin.automation-events': <FaClock />,
|
||||
|
||||
// Feature pages - Trustee
|
||||
'page.feature.trustee.dashboard': <FaChartLine />,
|
||||
|
|
@ -82,7 +83,9 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'feature.trustee': <FaBriefcase />,
|
||||
'feature.realestate': <FaBuilding />,
|
||||
'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.
|
||||
* 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] || <FaCog />;
|
||||
if (!PAGE_ICONS[uiComponent]) {
|
||||
console.warn(`[pageRegistry] Missing icon for uiComponent: "${uiComponent}"`);
|
||||
}
|
||||
return PAGE_ICONS[uiComponent] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ export function useDashboardInputForm(instanceId: string) {
|
|||
const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null);
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); // AI provider selection (multiselect)
|
||||
|
||||
const { checkPermission, canView } = usePermissions();
|
||||
const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(false);
|
||||
const { checkPermission } = usePermissions();
|
||||
const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(true);
|
||||
const [chatWorkflowPermission, setChatWorkflowPermission] = useState<any>(null);
|
||||
const [promptPermission, setPromptPermission] = useState<any>(null);
|
||||
const [filePermission, setFilePermission] = useState<any>(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<string, any> } | null) => {
|
||||
if (item === null) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<InstanceCardProps> = ({ instance, feature, mandateLabel }) => {
|
||||
const InstanceCard: React.FC<InstanceCardProps> = ({ 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<InstanceCardProps> = ({ instance, feature, mandateL
|
|||
<span className={styles.featureLabel}>{feature.uiLabel}</span>
|
||||
</div>
|
||||
<h3 className={styles.instanceLabel}>{instance.uiLabel}</h3>
|
||||
<p className={styles.mandateName}>{mandateLabel}</p>
|
||||
</div>
|
||||
<div className={styles.cardArrow}>
|
||||
<FaArrowRight />
|
||||
|
|
@ -94,25 +92,6 @@ export const DashboardPage: React.FC = () => {
|
|||
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 (
|
||||
<div className={styles.dashboard}>
|
||||
<header className={styles.header}>
|
||||
|
|
@ -123,24 +102,35 @@ export const DashboardPage: React.FC = () => {
|
|||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
{featureGroups.map(({ feature, instances }) => (
|
||||
<section key={feature.uiComponent} className={styles.featureSection}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{getPageIcon(feature.uiComponent)}
|
||||
<span>{feature.uiLabel}</span>
|
||||
</h2>
|
||||
<div className={styles.instanceGrid}>
|
||||
{instances.map(({ instance, mandateLabel }) => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
feature={feature}
|
||||
mandateLabel={mandateLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{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 (
|
||||
<section key={mandate.id} className={styles.featureSection}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
<FaBuilding />
|
||||
<span>{mandate.uiLabel}</span>
|
||||
</h2>
|
||||
<div className={styles.instanceGrid}>
|
||||
{mandateInstances.map(({ instance, feature }) => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
feature={feature}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -295,7 +295,10 @@ export const SettingsPage: React.FC = () => {
|
|||
<div className={styles.settingControl}>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => setIsProfileModalOpen(true)}
|
||||
onClick={async () => {
|
||||
await refetchUser();
|
||||
setIsProfileModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Profil öffnen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -549,7 +549,12 @@
|
|||
}
|
||||
|
||||
.logStatus {
|
||||
color: var(--primary-color, #f25843);
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.logEntryError .logStatus,
|
||||
.logEntryError .logMessage {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.logMessage {
|
||||
|
|
|
|||
223
src/pages/admin/AdminAutomationEventsPage.tsx
Normal file
223
src/pages/admin/AdminAutomationEventsPage.tsx
Normal 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)' }}>✓</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;
|
||||
|
|
@ -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<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [featureInstances, setFeatureInstances] = useState<FeatureInstance[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showUrlModal, setShowUrlModal] = useState<Invitation | null>(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) => {
|
||||
|
|
|
|||
|
|
@ -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<CleanupResult | null>(null);
|
||||
const [templateFixResult, setTemplateFixResult] = useState<TemplateFixResult | null>(null);
|
||||
const [cleanupError, setCleanupError] = useState<string | null>(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 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<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})`}
|
||||
|
|
@ -469,6 +491,81 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
</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>
|
||||
|
|
@ -477,13 +574,13 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
<button className={styles.secondaryButton} onClick={_closeCleanupModal}>
|
||||
{cleanupPhase === 'done' ? 'Schliessen' : 'Abbrechen'}
|
||||
</button>
|
||||
{cleanupPhase === 'preview' && cleanupResult && cleanupResult.duplicateRulesToDelete > 0 && (
|
||||
{cleanupPhase === 'preview' && cleanupResult && (cleanupResult.duplicateRulesToDelete > 0 || (templateFixResult && templateFixResult.invalidAssignments > 0)) && (
|
||||
<button
|
||||
className={styles.dangerButton}
|
||||
onClick={_executeCleanup}
|
||||
disabled={cleanupLoading}
|
||||
>
|
||||
<FaBroom /> {cleanupResult.duplicateRulesToDelete} Duplikate loeschen
|
||||
<FaBroom /> Bereinigung ausfuehren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,4 +14,5 @@ export { AdminMandateRolesPage } from './AdminMandateRolesPage';
|
|||
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
|
||||
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
|
||||
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
|
||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';
|
||||
|
|
@ -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 ? <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 },
|
||||
], []);
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className={styles.adminPage}>
|
||||
|
|
@ -179,15 +210,31 @@ export const AutomationTemplatesPage: React.FC = () => {
|
|||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
{
|
||||
type: 'custom' as const,
|
||||
icon: <FaCopy />,
|
||||
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={{
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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: <FaCopy />,
|
||||
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) => (
|
||||
<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>
|
||||
{log.status && <span className={styles.logStatus}><strong>{log.status}:</strong></span>}
|
||||
<span className={styles.logMessage}>{log.message}</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue