diff --git a/src/App.tsx b/src/App.tsx index f3de0df..5bac296 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { InvitePage } from './pages/InvitePage'; import { AuthProvider } from './providers/auth/AuthProvider'; import { ProtectedRoute } from './providers/auth/ProtectedRoute'; import { LanguageProvider } from './providers/language/LanguageContext'; +import { ToastProvider } from './contexts/ToastContext'; // Layouts import { MainLayout } from './layouts/MainLayout'; @@ -36,7 +37,7 @@ import { FeatureLayout } from './layouts/FeatureLayout'; import { DashboardPage } from './pages/Dashboard'; import { SettingsPage } from './pages/Settings'; import { FeatureViewPage } from './pages/FeatureView'; -import { AdminMandatesPage, AdminUsersPage, AdminRolesPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage } from './pages/admin'; +import { AdminMandatesPage, AdminUsersPage, AdminRolesPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureInstanceUsersPage } from './pages/admin'; function App() { // Load saved theme preference and set app name on app mount @@ -64,8 +65,9 @@ function App() { return ( - - + + + {/* ================================================== */} {/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */} {/* ================================================== */} @@ -123,6 +125,7 @@ function App() { } /> } /> } /> + } /> } /> } /> @@ -136,8 +139,9 @@ function App() { } /> - - + + + ); diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 0271cfc..61a0e72 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -18,7 +18,7 @@ margin-bottom: 10px; } -/* Table Container */ +/* Table Container - flipped to show horizontal scrollbar at top */ .tableContainer { position: relative; overflow: auto; @@ -30,6 +30,13 @@ min-height: 0; /* Ensure scrolling within container */ max-height: 100%; + /* Flip container to move horizontal scrollbar to top */ + transform: scaleY(-1); +} + +/* Flip table content back to normal orientation */ +.tableContainer > * { + transform: scaleY(-1); } /* Empty table styling - no extra space, just header */ diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 9c50184..4c84f42 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -22,7 +22,7 @@ import { useMandates, useFeatureStore } from '../../stores/featureStore'; import { useCurrentUser } from '../../hooks/useUsers'; import { FEATURE_REGISTRY, getLabel } from '../../types/mandate'; import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate'; -import { FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserShield, FaUserTag, FaCubes, FaEnvelopeOpenText, FaKey } from 'react-icons/fa'; +import { FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserShield, FaUserTag, FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog } from 'react-icons/fa'; import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; import styles from './MandateNavigation.module.css'; @@ -237,6 +237,12 @@ export const MandateNavigation: React.FC = () => { icon: , path: '/admin/feature-instances', }, + { + id: 'admin-feature-users', + label: 'Feature-Benutzer', + icon: , + path: '/admin/feature-users', + }, ], }); } diff --git a/src/components/UiComponents/Toast/Toast.module.css b/src/components/UiComponents/Toast/Toast.module.css new file mode 100644 index 0000000..c8363e8 --- /dev/null +++ b/src/components/UiComponents/Toast/Toast.module.css @@ -0,0 +1,130 @@ +.toastContainer { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 400px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + background: var(--surface-color, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + pointer-events: auto; + min-width: 300px; +} + +.toast.success { + border-left: 4px solid #22c55e; + background: linear-gradient(90deg, rgba(34, 197, 94, 0.08) 0%, var(--surface-color, #ffffff) 100%); +} + +.toast.error { + border-left: 4px solid #ef4444; + background: linear-gradient(90deg, rgba(239, 68, 68, 0.08) 0%, var(--surface-color, #ffffff) 100%); +} + +.toast.warning { + border-left: 4px solid #f59e0b; + background: linear-gradient(90deg, rgba(245, 158, 11, 0.08) 0%, var(--surface-color, #ffffff) 100%); +} + +.toast.info { + border-left: 4px solid #3b82f6; + background: linear-gradient(90deg, rgba(59, 130, 246, 0.08) 0%, var(--surface-color, #ffffff) 100%); +} + +.icon { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; +} + +.icon.success { + color: #22c55e; +} + +.icon.error { + color: #ef4444; +} + +.icon.warning { + color: #f59e0b; +} + +.icon.info { + color: #3b82f6; +} + +.content { + flex: 1; + min-width: 0; +} + +.title { + font-weight: 600; + font-size: 0.875rem; + color: var(--text-color, #1f2937); + margin: 0 0 0.25rem 0; +} + +.message { + font-size: 0.8125rem; + color: var(--text-secondary, #6b7280); + margin: 0; + white-space: pre-line; + line-height: 1.4; +} + +.closeButton { + flex-shrink: 0; + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: var(--text-secondary, #9ca3af); + border-radius: 4px; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.closeButton:hover { + background: var(--hover-color, rgba(0, 0, 0, 0.05)); + color: var(--text-color, #374151); +} + +/* Dark theme support */ +:global(.dark) .toast { + background: var(--surface-color, #1f2937); + border-color: var(--border-color, #374151); +} + +:global(.dark) .toast.success { + background: linear-gradient(90deg, rgba(34, 197, 94, 0.12) 0%, var(--surface-color, #1f2937) 100%); +} + +:global(.dark) .toast.error { + background: linear-gradient(90deg, rgba(239, 68, 68, 0.12) 0%, var(--surface-color, #1f2937) 100%); +} + +:global(.dark) .toast.warning { + background: linear-gradient(90deg, rgba(245, 158, 11, 0.12) 0%, var(--surface-color, #1f2937) 100%); +} + +:global(.dark) .toast.info { + background: linear-gradient(90deg, rgba(59, 130, 246, 0.12) 0%, var(--surface-color, #1f2937) 100%); +} diff --git a/src/components/UiComponents/Toast/Toast.tsx b/src/components/UiComponents/Toast/Toast.tsx new file mode 100644 index 0000000..ed80533 --- /dev/null +++ b/src/components/UiComponents/Toast/Toast.tsx @@ -0,0 +1,76 @@ +import { motion, AnimatePresence } from 'framer-motion'; +import { FaCheckCircle, FaExclamationCircle, FaExclamationTriangle, FaInfoCircle, FaTimes } from 'react-icons/fa'; +import styles from './Toast.module.css'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface ToastData { + id: string; + type: ToastType; + title: string; + message?: string; + duration?: number; +} + +interface ToastProps { + toast: ToastData; + onClose: (id: string) => void; +} + +const _getIcon = (type: ToastType) => { + switch (type) { + case 'success': + return ; + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + } +}; + +export const Toast: React.FC = ({ toast, onClose }) => { + return ( + +
+ {_getIcon(toast.type)} +
+
+

{toast.title}

+ {toast.message &&

{toast.message}

} +
+ +
+ ); +}; + +interface ToastContainerProps { + toasts: ToastData[]; + onClose: (id: string) => void; +} + +export const ToastContainer: React.FC = ({ toasts, onClose }) => { + return ( +
+ + {toasts.map((toast) => ( + + ))} + +
+ ); +}; diff --git a/src/components/UiComponents/Toast/index.ts b/src/components/UiComponents/Toast/index.ts new file mode 100644 index 0000000..73df98b --- /dev/null +++ b/src/components/UiComponents/Toast/index.ts @@ -0,0 +1,2 @@ +export { Toast, ToastContainer } from './Toast'; +export type { ToastType, ToastData } from './Toast'; diff --git a/src/components/UiComponents/index.ts b/src/components/UiComponents/index.ts index 816fc3e..15480f1 100644 --- a/src/components/UiComponents/index.ts +++ b/src/components/UiComponents/index.ts @@ -19,3 +19,4 @@ export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes'; export * from './AutoScroll'; export * from './Tabs'; export type { TabsProps, Tab } from './Tabs'; +export * from './Toast'; diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..5d0b469 --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,97 @@ +import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'; +import { ToastContainer, ToastData, ToastType } from '../components/UiComponents/Toast'; + +interface ToastOptions { + title: string; + message?: string; + duration?: number; +} + +interface ToastContextValue { + showToast: (type: ToastType, options: ToastOptions) => void; + showSuccess: (title: string, message?: string) => void; + showError: (title: string, message?: string) => void; + showWarning: (title: string, message?: string) => void; + showInfo: (title: string, message?: string) => void; + closeToast: (id: string) => void; +} + +const ToastContext = createContext(null); + +const DEFAULT_DURATION = 5000; + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState([]); + const timeoutRefs = useRef>(new Map()); + + const closeToast = useCallback((id: string) => { + // Clear timeout if exists + const timeout = timeoutRefs.current.get(id); + if (timeout) { + clearTimeout(timeout); + timeoutRefs.current.delete(id); + } + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const showToast = useCallback((type: ToastType, options: ToastOptions) => { + const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const duration = options.duration ?? DEFAULT_DURATION; + + const newToast: ToastData = { + id, + type, + title: options.title, + message: options.message, + duration, + }; + + setToasts((prev) => [...prev, newToast]); + + // Auto-close after duration + if (duration > 0) { + const timeout = setTimeout(() => { + closeToast(id); + }, duration); + timeoutRefs.current.set(id, timeout); + } + }, [closeToast]); + + const showSuccess = useCallback((title: string, message?: string) => { + showToast('success', { title, message }); + }, [showToast]); + + const showError = useCallback((title: string, message?: string) => { + showToast('error', { title, message, duration: 8000 }); // Errors stay longer + }, [showToast]); + + const showWarning = useCallback((title: string, message?: string) => { + showToast('warning', { title, message, duration: 6000 }); + }, [showToast]); + + const showInfo = useCallback((title: string, message?: string) => { + showToast('info', { title, message }); + }, [showToast]); + + // Cleanup timeouts on unmount + useEffect(() => { + return () => { + timeoutRefs.current.forEach((timeout) => clearTimeout(timeout)); + }; + }, []); + + return ( + + {children} + + + ); +}; + +export const useToast = (): ToastContextValue => { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +}; diff --git a/src/core/PageManager/PageRenderer.tsx b/src/core/PageManager/PageRenderer.tsx index 9fb2e01..12a219e 100644 --- a/src/core/PageManager/PageRenderer.tsx +++ b/src/core/PageManager/PageRenderer.tsx @@ -2357,8 +2357,6 @@ const PageRenderer: React.FC = ({ - {/* Message Overlay Component */} - {hookData?.MessageOverlayComponent && } ); diff --git a/src/core/PageManager/data/pages/files.ts b/src/core/PageManager/data/pages/files.ts index 1526e05..b61b1fb 100644 --- a/src/core/PageManager/data/pages/files.ts +++ b/src/core/PageManager/data/pages/files.ts @@ -82,8 +82,7 @@ const createFilesHook = () => { previewingFiles, editingFiles, downloadingFiles, - uploadingFile, - MessageOverlayComponent + uploadingFile } = useFileOperations(); const generatedColumns = attributes && attributes.length > 0 @@ -148,8 +147,6 @@ const createFilesHook = () => { uploadingFile, // Upload functionality for button and drag-and-drop handleUpload, - // Message overlay component - MessageOverlayComponent, // Attributes and permissions for dynamic column/button generation attributes, permissions, diff --git a/src/core/PageManager/pageInterface.ts b/src/core/PageManager/pageInterface.ts index 18d8447..41882f0 100644 --- a/src/core/PageManager/pageInterface.ts +++ b/src/core/PageManager/pageInterface.ts @@ -251,8 +251,6 @@ export interface GenericDataHook { dashboardTree?: any; // Dashboard log tree structure onToggleOperationExpanded?: (operationId: string) => void; getChildOperations?: (parentId: string | null) => string[]; - // Message overlay component - MessageOverlayComponent?: () => React.ReactElement; // Settings-specific properties settingsData?: any; // Unified data object for settings fields settingsFields?: Record; // Field definitions per sectionId diff --git a/src/hooks/useFeatureAccess.ts b/src/hooks/useFeatureAccess.ts index c7a5f49..51eb479 100644 --- a/src/hooks/useFeatureAccess.ts +++ b/src/hooks/useFeatureAccess.ts @@ -5,10 +5,25 @@ * Uses the /api/features endpoints. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import api from '../api'; // Types +export interface PaginationParams { + page?: number; + pageSize?: number; + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; +} + +export interface PaginationMetadata { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + export interface Feature { code: string; label: string | { [key: string]: string }; @@ -33,15 +48,29 @@ export interface FeatureAccess { } export interface FeatureAccessUser { + id: string; // FeatureAccess ID as primary key userId: string; username: string; email?: string; fullName?: string; - featureAccessId: string; roleIds: string[]; + roleLabels: string[]; enabled: boolean; } +export interface FeatureInstanceRole { + id: string; + roleLabel: string; + description?: { [key: string]: string }; + featureCode?: string; + isSystemRole?: boolean; +} + +export interface AddUserToInstanceRequest { + userId: string; + roleIds: string[]; +} + export interface FeatureInstanceCreate { featureCode: string; label: string; @@ -56,6 +85,11 @@ export function useFeatureAccess() { const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [instancesPagination, setInstancesPagination] = useState(null); + + // Store current context for refetch + const currentMandateIdRef = useRef(''); + const currentFeatureCodeRef = useRef(undefined); /** * Fetch all available features @@ -79,29 +113,73 @@ export function useFeatureAccess() { }, []); /** - * Fetch feature instances for a mandate + * Fetch feature instances for a mandate with optional pagination */ - const fetchInstances = useCallback(async (mandateId: string, featureCode?: string): Promise => { + const fetchInstances = useCallback(async ( + mandateIdOrPagination?: string | PaginationParams, + featureCode?: string + ): Promise => { setLoading(true); setError(null); + + let mandateId: string; + let paginationParams: PaginationParams = {}; + + // Handle backward compatibility + if (typeof mandateIdOrPagination === 'string') { + mandateId = mandateIdOrPagination; + currentMandateIdRef.current = mandateId; + currentFeatureCodeRef.current = featureCode; + } else if (mandateIdOrPagination && typeof mandateIdOrPagination === 'object') { + paginationParams = mandateIdOrPagination; + mandateId = currentMandateIdRef.current; + featureCode = currentFeatureCodeRef.current; + } else { + mandateId = currentMandateIdRef.current; + featureCode = currentFeatureCodeRef.current; + } + + if (!mandateId) { + setLoading(false); + return []; + } + try { - let url = '/api/features/instances'; + const params = new URLSearchParams(); if (featureCode) { - url += `?featureCode=${encodeURIComponent(featureCode)}`; + params.append('featureCode', featureCode); } + if (Object.keys(paginationParams).length > 0) { + params.append('pagination', JSON.stringify(paginationParams)); + } + + const url = params.toString() + ? `/api/features/instances?${params.toString()}` + : '/api/features/instances'; const response = await api.get(url, { headers: { 'X-Mandate-Id': mandateId } }); - const data = Array.isArray(response.data) ? response.data : []; + + let data: FeatureInstance[] = []; + if (response.data?.items && Array.isArray(response.data.items)) { + data = response.data.items; + if (response.data.pagination) { + setInstancesPagination(response.data.pagination); + } + } else { + data = Array.isArray(response.data) ? response.data : []; + } + setInstances(data); return data; } catch (err: any) { const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch feature instances'; setError(errorMessage); setInstances([]); + setInstancesPagination(null); return []; } finally { setLoading(false); @@ -231,9 +309,142 @@ export function useFeatureAccess() { } }, []); + // ============================================ + // Feature Instance Users Management + // ============================================ + + /** + * Fetch all users with access to a specific feature instance + */ + const fetchInstanceUsers = useCallback(async ( + mandateId: string, + instanceId: string + ): Promise => { + setLoading(true); + setError(null); + try { + const response = await api.get(`/api/features/instances/${instanceId}/users`, { + headers: { + 'X-Mandate-Id': mandateId + } + }); + return Array.isArray(response.data) ? response.data : []; + } catch (err: any) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch instance users'; + setError(errorMessage); + return []; + } finally { + setLoading(false); + } + }, []); + + /** + * Add a user to a feature instance with specified roles + */ + const addUserToInstance = useCallback(async ( + mandateId: string, + instanceId: string, + data: AddUserToInstanceRequest + ): Promise<{ success: boolean; data?: any; error?: string }> => { + setLoading(true); + setError(null); + try { + const response = await api.post(`/api/features/instances/${instanceId}/users`, data, { + headers: { + 'X-Mandate-Id': mandateId + } + }); + return { success: true, data: response.data }; + } catch (err: any) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to add user to instance'; + setError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setLoading(false); + } + }, []); + + /** + * Remove a user's access from a feature instance + */ + const removeUserFromInstance = useCallback(async ( + mandateId: string, + instanceId: string, + userId: string + ): Promise<{ success: boolean; error?: string }> => { + setLoading(true); + setError(null); + try { + await api.delete(`/api/features/instances/${instanceId}/users/${userId}`, { + headers: { + 'X-Mandate-Id': mandateId + } + }); + return { success: true }; + } catch (err: any) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to remove user from instance'; + setError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setLoading(false); + } + }, []); + + /** + * Update a user's roles in a feature instance + */ + const updateInstanceUserRoles = useCallback(async ( + mandateId: string, + instanceId: string, + userId: string, + roleIds: string[] + ): Promise<{ success: boolean; data?: any; error?: string }> => { + setLoading(true); + setError(null); + try { + const response = await api.put( + `/api/features/instances/${instanceId}/users/${userId}/roles`, + roleIds, + { + headers: { + 'X-Mandate-Id': mandateId + } + } + ); + return { success: true, data: response.data }; + } catch (err: any) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to update user roles'; + setError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setLoading(false); + } + }, []); + + /** + * Get available roles for a feature instance + */ + const fetchInstanceRoles = useCallback(async ( + mandateId: string, + instanceId: string + ): Promise => { + try { + const response = await api.get(`/api/features/instances/${instanceId}/available-roles`, { + headers: { + 'X-Mandate-Id': mandateId + } + }); + return Array.isArray(response.data) ? response.data : []; + } catch (err: any) { + console.error('Error fetching instance roles:', err); + return []; + } + }, []); + return { features, instances, + instancesPagination, loading, error, fetchFeatures, @@ -243,6 +454,12 @@ export function useFeatureAccess() { syncInstanceRoles, fetchMyFeatureInstances, fetchTemplateRoles, + // Instance users management + fetchInstanceUsers, + addUserToInstance, + removeUserFromInstance, + updateInstanceUserRoles, + fetchInstanceRoles, }; } diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index d551804..5813aa5 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -1,7 +1,6 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import api from '../api'; -import { MessageOverlay } from '../components/UiComponents'; -import type { MessageMode } from '../components/UiComponents'; +import { useToast } from '../contexts/ToastContext'; import { useLanguage } from '../providers/language/LanguageContext'; import { getUserDataCache } from '../utils/userCache'; import { useApiRequest } from './useApi'; @@ -319,9 +318,8 @@ export function useFileOperations() { const [previewingFiles, setPreviewingFiles] = useState>(new Set()); const [previewError, setPreviewError] = useState(null); - // Warning state - const [showWarning, setShowWarning] = useState(false); - const [warningData, setWarningData] = useState<{ header: string; message: string; mode: MessageMode } | null>(null); + // Toast for notifications + const { showWarning } = useToast(); // Language context const { t } = useLanguage(); @@ -502,27 +500,7 @@ export function useFileOperations() { const fileName = fileData.originalFileName || file.name; const messageTemplate = t('warning.duplicate_file.message'); const message = messageTemplate.replace('{fileName}', fileName); - - // Close any existing warning first - if (showWarning) { - setShowWarning(false); - // Wait a moment before showing the new warning - setTimeout(() => { - setWarningData({ - header: t('warning.duplicate_file.title'), - message: message, - mode: 'warning' - }); - setShowWarning(true); - }, 600); - } else { - setWarningData({ - header: t('warning.duplicate_file.title'), - message: message, - mode: 'warning' - }); - setShowWarning(true); - } + showWarning(t('warning.duplicate_file.title'), message); } return { success: true, fileData }; @@ -936,15 +914,6 @@ export function useFileOperations() { } }; - // Function to close warning - const closeWarning = useCallback(() => { - setShowWarning(false); - // Delay clearing the data to allow exit animation to complete (matches CSS transition) - setTimeout(() => { - setWarningData(null); - }, 700); - }, []); - return { downloadingFiles, deletingFiles, @@ -961,16 +930,6 @@ export function useFileOperations() { handleFileUpload, handleFileUpdate, handleFilePreview, - isLoading, - // Message overlay component - MessageOverlayComponent: () => React.createElement(MessageOverlay, { - header: warningData?.header || '', - message: warningData?.message || '', - isVisible: showWarning, - mode: warningData?.mode || 'info', - onClose: closeWarning, - autoClose: true, - autoCloseDelay: 5000 - }) + isLoading }; } \ No newline at end of file diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts index f3e4fc6..61ef97a 100644 --- a/src/hooks/useInvitations.ts +++ b/src/hooks/useInvitations.ts @@ -5,10 +5,25 @@ * Uses the /api/invitations endpoints. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import api from '../api'; // Types +export interface PaginationParams { + page?: number; + pageSize?: number; + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; +} + +export interface PaginationMetadata { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + export interface Invitation { id: string; token: string; @@ -63,31 +78,76 @@ export function useInvitations() { const [invitations, setInvitations] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [pagination, setPagination] = useState(null); + + // Store current context for refetch + const currentMandateIdRef = useRef(''); + const currentOptionsRef = useRef<{ includeUsed?: boolean; includeExpired?: boolean }>({}); /** - * Fetch all invitations for a mandate + * Fetch all invitations for a mandate with optional pagination */ const fetchInvitations = useCallback(async ( - mandateId: string, + mandateIdOrPagination?: string | PaginationParams, options?: { includeUsed?: boolean; includeExpired?: boolean } ): Promise => { setLoading(true); setError(null); + + let mandateId: string; + let paginationParams: PaginationParams = {}; + + // Handle backward compatibility + if (typeof mandateIdOrPagination === 'string') { + mandateId = mandateIdOrPagination; + currentMandateIdRef.current = mandateId; + if (options) { + currentOptionsRef.current = options; + } + } else if (mandateIdOrPagination && typeof mandateIdOrPagination === 'object') { + // Called with pagination params only (refetch from FormGeneratorTable) + paginationParams = mandateIdOrPagination; + mandateId = currentMandateIdRef.current; + } else { + mandateId = currentMandateIdRef.current; + } + + if (!mandateId) { + setLoading(false); + return []; + } + + const fetchOptions = options || currentOptionsRef.current; + try { const params = new URLSearchParams(); - if (options?.includeUsed) params.append('includeUsed', 'true'); - if (options?.includeExpired) params.append('includeExpired', 'true'); + if (fetchOptions?.includeUsed) params.append('includeUsed', 'true'); + if (fetchOptions?.includeExpired) params.append('includeExpired', 'true'); + if (Object.keys(paginationParams).length > 0) { + params.append('pagination', JSON.stringify(paginationParams)); + } const response = await api.get(`/api/invitations/?${params.toString()}`, { headers: { 'X-Mandate-Id': mandateId } }); - const data = Array.isArray(response.data) ? response.data : []; + + let data: Invitation[] = []; + if (response.data?.items && Array.isArray(response.data.items)) { + data = response.data.items; + if (response.data.pagination) { + setPagination(response.data.pagination); + } + } else { + data = Array.isArray(response.data) ? response.data : []; + } + setInvitations(data); return data; } catch (err: any) { const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch invitations'; setError(errorMessage); setInvitations([]); + setPagination(null); return []; } finally { setLoading(false); @@ -210,6 +270,7 @@ export function useInvitations() { invitations, loading, error, + pagination, fetchInvitations, createInvitation, revokeInvitation, diff --git a/src/hooks/useMandateRoles.ts b/src/hooks/useMandateRoles.ts index 8abe8aa..3f59a73 100644 --- a/src/hooks/useMandateRoles.ts +++ b/src/hooks/useMandateRoles.ts @@ -5,7 +5,7 @@ * Uses the /api/rbac/roles endpoints. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import api from '../api'; // Types @@ -36,6 +36,21 @@ export interface RoleUpdate { mandateId?: string | null; } +export interface PaginationParams { + page?: number; + pageSize?: number; + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; +} + +export interface PaginationMetadata { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + /** * Hook for managing mandate roles */ @@ -43,39 +58,78 @@ export function useMandateRoles() { const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [pagination, setPagination] = useState(null); + + // Store current mandateId for refetch + const currentMandateIdRef = useRef(); /** - * Fetch all roles (optionally filtered by mandate) + * Fetch all roles with pagination support + * @param mandateIdOrParams - Either a mandateId string (backward compatible) or pagination params */ - const fetchRoles = useCallback(async (mandateId?: string): Promise => { + const fetchRoles = useCallback(async ( + mandateIdOrParams?: string | PaginationParams + ): Promise => { setLoading(true); setError(null); + try { const headers: Record = {}; + let paginationParams: PaginationParams = {}; + let mandateId: string | undefined; + + // Handle backward compatibility: first param can be mandateId string or pagination object + if (typeof mandateIdOrParams === 'string') { + mandateId = mandateIdOrParams; + currentMandateIdRef.current = mandateId; + } else if (mandateIdOrParams && typeof mandateIdOrParams === 'object') { + paginationParams = mandateIdOrParams; + mandateId = currentMandateIdRef.current; + } + if (mandateId) { headers['X-Mandate-Id'] = mandateId; } - const response = await api.get('/api/rbac/roles', { headers }); + // Build query params for pagination + const queryParams: Record = {}; + if (Object.keys(paginationParams).length > 0) { + queryParams.pagination = JSON.stringify(paginationParams); + } + // Include templates by default for mandate roles view + queryParams.includeTemplates = 'true'; + + const response = await api.get('/api/rbac/roles', { + headers, + params: queryParams + }); + let data: Role[] = []; + let paginationMeta: PaginationMetadata | null = null; if (response.data?.items && Array.isArray(response.data.items)) { data = response.data.items; + if (response.data.pagination) { + paginationMeta = response.data.pagination; + } } else if (Array.isArray(response.data)) { data = response.data; } // Filter to only show roles for this mandate (or global roles) - if (mandateId) { + // Only do client-side filtering if no pagination was requested + if (mandateId && Object.keys(paginationParams).length === 0) { data = data.filter(r => !r.mandateId || r.mandateId === mandateId); } setRoles(data); + setPagination(paginationMeta); return data; } catch (err: any) { const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch roles'; setError(errorMessage); setRoles([]); + setPagination(null); return []; } finally { setLoading(false); @@ -225,6 +279,7 @@ export function useMandateRoles() { roles, loading, error, + pagination, fetchRoles, getRole, createRole, diff --git a/src/hooks/useUserMandates.ts b/src/hooks/useUserMandates.ts index a7c442e..61758dd 100644 --- a/src/hooks/useUserMandates.ts +++ b/src/hooks/useUserMandates.ts @@ -5,17 +5,32 @@ * Uses the /api/mandates/{mandateId}/users endpoints. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import api from '../api'; // Types +export interface PaginationParams { + page?: number; + pageSize?: number; + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; +} + +export interface PaginationMetadata { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + export interface MandateUser { + id: string; // UserMandate ID as primary key userId: string; username: string; email: string | null; firstname: string | null; lastname: string | null; - userMandateId: string; roleIds: string[]; enabled: boolean; } @@ -26,7 +41,7 @@ export interface UserMandateCreate { } export interface UserMandateResponse { - userMandateId: string; + id: string; // UserMandate ID as primary key userId: string; mandateId: string; roleIds: string[]; @@ -56,22 +71,68 @@ export function useUserMandates() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [pagination, setPagination] = useState(null); + + // Store current mandate for refetch + const currentMandateIdRef = useRef(''); /** - * Fetch all users in a specific mandate + * Fetch all users in a specific mandate with optional pagination */ - const fetchMandateUsers = useCallback(async (mandateId: string): Promise => { + const fetchMandateUsers = useCallback(async ( + mandateIdOrPagination?: string | PaginationParams + ): Promise => { setLoading(true); setError(null); + + let mandateId: string; + let paginationParams: PaginationParams = {}; + + // Handle backward compatibility + if (typeof mandateIdOrPagination === 'string') { + mandateId = mandateIdOrPagination; + currentMandateIdRef.current = mandateId; + } else if (mandateIdOrPagination && typeof mandateIdOrPagination === 'object') { + paginationParams = mandateIdOrPagination; + mandateId = currentMandateIdRef.current; + } else { + mandateId = currentMandateIdRef.current; + } + + if (!mandateId) { + setLoading(false); + return []; + } + try { - const response = await api.get(`/api/mandates/${mandateId}/users`); - const data = Array.isArray(response.data) ? response.data : []; + const params = new URLSearchParams(); + if (Object.keys(paginationParams).length > 0) { + params.append('pagination', JSON.stringify(paginationParams)); + } + + const url = params.toString() + ? `/api/mandates/${mandateId}/users?${params.toString()}` + : `/api/mandates/${mandateId}/users`; + + const response = await api.get(url); + + let data: MandateUser[] = []; + if (response.data?.items && Array.isArray(response.data.items)) { + data = response.data.items; + if (response.data.pagination) { + setPagination(response.data.pagination); + } + } else { + data = Array.isArray(response.data) ? response.data : []; + } + setUsers(data); return data; } catch (err: any) { const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch mandate users'; setError(errorMessage); setUsers([]); + setPagination(null); return []; } finally { setLoading(false); @@ -210,6 +271,7 @@ export function useUserMandates() { users, loading, error, + pagination, fetchMandateUsers, addUserToMandate, removeUserFromMandate, diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index df6eaf9..d0ad589 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useApiRequest } from './useApi'; import { deleteWorkflowApi, @@ -15,11 +15,7 @@ import { type AttributeDefinition, type StartWorkflowRequest } from '../api/workflowApi'; -import { MessageOverlay } from '../components/UiComponents'; -import type { MessageMode } from '../components/UiComponents'; -import { useLanguage } from '../providers/language/LanguageContext'; import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext'; -// import { getUserDataCache } from '../utils/userCache'; // Unused import import { usePermissions, type UserPermissions } from './usePermissions'; // Workflow interface matching backend @@ -395,13 +391,6 @@ export function useWorkflowOperations() { const [deleteMessageError, setDeleteMessageError] = useState(null); const [deleteFileError, setDeleteFileError] = useState(null); - // Warning state - const [showWarning, setShowWarning] = useState(false); - const [warningData, setWarningData] = useState<{ header: string; message: string; mode: MessageMode } | null>(null); - - // Language context - const { t: _t } = useLanguage(); - // Workflow selection context - to clear selection if deleted workflow is selected const { selectedWorkflowId, clearWorkflow } = useWorkflowSelection(); @@ -618,15 +607,6 @@ export function useWorkflowOperations() { } }; - // Function to close warning - const closeWarning = useCallback(() => { - setShowWarning(false); - // Delay clearing the data to allow exit animation to complete (matches CSS transition) - setTimeout(() => { - setWarningData(null); - }, 700); - }, []); - return { // Loading states startingWorkflow, @@ -649,16 +629,6 @@ export function useWorkflowOperations() { handleWorkflowDeleteMultiple, handleWorkflowUpdate, deleteMessage, - deleteFileFromMessage, - // Message overlay component - MessageOverlayComponent: () => React.createElement(MessageOverlay, { - header: warningData?.header || '', - message: warningData?.message || '', - isVisible: showWarning, - mode: warningData?.mode || 'info', - onClose: closeWarning, - autoClose: true, - autoCloseDelay: 5000 - }) + deleteFileFromMessage }; } diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index c4b58fa..12933ae 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -144,7 +144,7 @@ .tableContainer { flex: 1; min-height: 0; - overflow: hidden; + overflow: auto; background: var(--surface-color); border: 1px solid var(--border-color); border-radius: 8px; diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index c8da302..6517db1 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -6,11 +6,12 @@ */ import React, { useState, useEffect, useMemo } from 'react'; -import { useFeatureAccess, type Feature, type FeatureInstance } from '../../hooks/useFeatureAccess'; +import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAccess'; import { useUserMandates, type Mandate } from '../../hooks/useUserMandates'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs } from 'react-icons/fa'; +import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; import styles from './Admin.module.css'; @@ -18,6 +19,7 @@ export const AdminFeatureAccessPage: React.FC = () => { const { features, instances, + instancesPagination, loading, error, fetchFeatures, @@ -28,12 +30,13 @@ export const AdminFeatureAccessPage: React.FC = () => { } = useFeatureAccess(); const { fetchMandates } = useUserMandates(); + const { showSuccess, showError } = useToast(); // State const [mandates, setMandates] = useState([]); const [selectedMandateId, setSelectedMandateId] = useState(''); const [showCreateModal, setShowCreateModal] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); + const [, setIsSubmitting] = useState(false); const [syncingInstance, setSyncingInstance] = useState(null); const [backendAttributes, setBackendAttributes] = useState([]); @@ -107,8 +110,9 @@ export const AdminFeatureAccessPage: React.FC = () => { if (result.success) { setShowCreateModal(false); fetchInstances(selectedMandateId); + showSuccess('Feature-Instanz erstellt', `Die Instanz "${data.label}" wurde erfolgreich erstellt.`); } else { - alert(result.error || 'Fehler beim Erstellen der Feature-Instanz'); + showError('Fehler', result.error || 'Fehler beim Erstellen der Feature-Instanz'); } } finally { setIsSubmitting(false); @@ -120,8 +124,10 @@ export const AdminFeatureAccessPage: React.FC = () => { if (!selectedMandateId) return; if (window.confirm(`Möchten Sie die Feature-Instanz "${instance.label}" wirklich löschen? Alle zugehörigen Daten werden gelöscht.`)) { const result = await deleteInstance(selectedMandateId, instance.id); - if (!result.success) { - alert(result.error || 'Fehler beim Löschen der Feature-Instanz'); + if (result.success) { + showSuccess('Instanz gelöscht', `Die Feature-Instanz "${instance.label}" wurde gelöscht.`); + } else { + showError('Fehler', result.error || 'Fehler beim Löschen der Feature-Instanz'); } } }; @@ -133,9 +139,12 @@ export const AdminFeatureAccessPage: React.FC = () => { try { const result = await syncInstanceRoles(selectedMandateId, instance.id, true); if (result.success && result.data) { - alert(`Rollen synchronisiert:\n- Hinzugefügt: ${result.data.added}\n- Entfernt: ${result.data.removed}\n- Unverändert: ${result.data.unchanged}`); + showSuccess( + 'Rollen synchronisiert', + `Hinzugefügt: ${result.data.added}\nEntfernt: ${result.data.removed}\nUnverändert: ${result.data.unchanged}` + ); } else { - alert(result.error || 'Fehler beim Synchronisieren der Rollen'); + showError('Synchronisierung fehlgeschlagen', result.error || 'Fehler beim Synchronisieren der Rollen'); } } finally { setSyncingInstance(null); @@ -297,7 +306,8 @@ export const AdminFeatureAccessPage: React.FC = () => { ]} onDelete={handleDeleteInstance} hookData={{ - refetch: () => fetchInstances(selectedMandateId), + refetch: fetchInstances, + pagination: instancesPagination, handleDelete: handleDeleteInstance, }} emptyMessage="Keine Feature-Instanzen gefunden" diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx new file mode 100644 index 0000000..5a520ab --- /dev/null +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -0,0 +1,577 @@ +/** + * AdminFeatureInstanceUsersPage + * + * Admin page for managing user access to feature instances. + * Allows adding, removing, and updating user roles within feature instances. + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useFeatureAccess, type FeatureAccessUser, type FeatureInstanceRole, type PaginationParams, type PaginationMetadata } from '../../hooks/useFeatureAccess'; +import { useUserMandates, type Mandate } from '../../hooks/useUserMandates'; +import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; +import { FaPlus, FaSync, FaUsers, FaBuilding, FaCube } from 'react-icons/fa'; +import { useToast } from '../../contexts/ToastContext'; +import api from '../../api'; +import styles from './Admin.module.css'; + +export const AdminFeatureInstanceUsersPage: React.FC = () => { + const { + features, + instances, + loading, + error, + fetchFeatures, + fetchInstances, + fetchInstanceUsers, + addUserToInstance, + removeUserFromInstance, + updateInstanceUserRoles, + fetchInstanceRoles, + } = useFeatureAccess(); + + const { fetchMandates } = useUserMandates(); + const { showSuccess, showError } = useToast(); + + // State + const [mandates, setMandates] = useState([]); + const [selectedMandateId, setSelectedMandateId] = useState(''); + const [selectedInstanceId, setSelectedInstanceId] = useState(''); + const [instanceUsers, setInstanceUsers] = useState([]); + const [instanceRoles, setInstanceRoles] = useState([]); + const [allUsers, setAllUsers] = useState>([]); + const [showAddModal, setShowAddModal] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [, setIsSubmitting] = useState(false); + const [usersLoading, setUsersLoading] = useState(false); + const [usersPagination, setUsersPagination] = useState(null); + + // Load mandates and features on mount + useEffect(() => { + fetchFeatures(); + fetchMandates().then(setMandates); + }, [fetchFeatures, fetchMandates]); + + // Load instances when mandate changes + useEffect(() => { + if (selectedMandateId) { + fetchInstances(selectedMandateId); + setSelectedInstanceId(''); + setInstanceUsers([]); + setInstanceRoles([]); + } + }, [selectedMandateId, fetchInstances]); + + // Load users and roles when instance changes + useEffect(() => { + if (selectedMandateId && selectedInstanceId) { + setUsersLoading(true); + Promise.all([ + fetchInstanceUsers(selectedMandateId, selectedInstanceId), + fetchInstanceRoles(selectedMandateId, selectedInstanceId), + ]).then(([users, roles]) => { + setInstanceUsers(users); + setInstanceRoles(roles); + }).finally(() => { + setUsersLoading(false); + }); + } + }, [selectedMandateId, selectedInstanceId, fetchInstanceUsers, fetchInstanceRoles]); + + // Load mandate members for the add modal (only users who are members of the selected mandate) + useEffect(() => { + if (!selectedMandateId) { + setAllUsers([]); + return; + } + api.get(`/api/mandates/${selectedMandateId}/users`).then(response => { + const data = response.data?.items || response.data || []; + // Map MandateUserInfo to the expected format + const mappedUsers = Array.isArray(data) ? data.map((u: any) => ({ + id: u.userId, + username: u.username, + email: u.email, + fullName: u.fullName + })) : []; + setAllUsers(mappedUsers); + }).catch(() => setAllUsers([])); + }, [selectedMandateId]); + + // Refresh instance users with optional pagination + const refreshUsers = useCallback(async (paginationParams?: PaginationParams) => { + if (selectedMandateId && selectedInstanceId) { + setUsersLoading(true); + try { + // Build query params + const params = new URLSearchParams(); + if (paginationParams && Object.keys(paginationParams).length > 0) { + params.append('pagination', JSON.stringify(paginationParams)); + } + + const url = params.toString() + ? `/api/features/instances/${selectedInstanceId}/users?${params.toString()}` + : `/api/features/instances/${selectedInstanceId}/users`; + + const response = await api.get(url, { + headers: { 'X-Mandate-Id': selectedMandateId } + }); + + if (response.data?.items && Array.isArray(response.data.items)) { + setInstanceUsers(response.data.items); + if (response.data.pagination) { + setUsersPagination(response.data.pagination); + } + } else { + const users = Array.isArray(response.data) ? response.data : []; + setInstanceUsers(users); + } + } catch (err) { + console.error('Error refreshing users:', err); + setInstanceUsers([]); + } finally { + setUsersLoading(false); + } + } + }, [selectedMandateId, selectedInstanceId]); + + // Get users not yet in the instance + const availableUsers = useMemo(() => { + const existingUserIds = new Set(instanceUsers.map(u => u.userId)); + return allUsers.filter(u => !existingUserIds.has(u.id)); + }, [allUsers, instanceUsers]); + + // Table columns + const columns = useMemo(() => [ + { + key: 'username', + label: 'Benutzername', + type: 'text' as const, + sortable: true, + filterable: true, + searchable: true, + width: 150, + }, + { + key: 'email', + label: 'E-Mail', + type: 'text' as const, + sortable: true, + filterable: true, + searchable: true, + width: 200, + }, + { + key: 'fullName', + label: 'Vollständiger Name', + type: 'text' as const, + sortable: true, + filterable: true, + searchable: true, + width: 180, + }, + { + key: 'roleLabels', + label: 'Rollen', + type: 'text' as const, + sortable: false, + filterable: false, + searchable: true, + width: 200, + render: (value: string[]) => { + if (!value || value.length === 0) return '-'; + return value.join(', '); + }, + }, + { + key: 'enabled', + label: 'Aktiv', + type: 'boolean' as const, + sortable: true, + filterable: true, + searchable: false, + width: 80, + }, + ], []); + + // Dynamic options for forms (users and roles) + const userOptions = useMemo(() => + availableUsers.map(u => ({ + value: u.id, + label: `${u.username} ${u.email ? `(${u.email})` : ''}` + })), [availableUsers]); + + const roleOptions = useMemo(() => + instanceRoles.map(r => ({ + value: r.id, + label: r.roleLabel + })), [instanceRoles]); + + // Form attributes for adding a user + const addUserFields: AttributeDefinition[] = useMemo(() => { + return [ + { + name: 'userId', + label: 'Benutzer', + type: 'enum' as const, + required: true, + options: userOptions, + }, + { + name: 'roleIds', + label: 'Rollen', + type: 'multiselect' as const, + required: true, + options: roleOptions, + } + ]; + }, [userOptions, roleOptions]); + + // Form attributes for editing user roles + const editRolesFields: AttributeDefinition[] = useMemo(() => { + return [{ + name: 'roleIds', + label: 'Rollen', + type: 'multiselect' as const, + required: true, + options: roleOptions, + }]; + }, [roleOptions]); + + // Handle add user submit + const handleAddUser = async (data: { userId: string; roleIds: string[] }) => { + if (!selectedMandateId || !selectedInstanceId) return; + setIsSubmitting(true); + try { + const result = await addUserToInstance(selectedMandateId, selectedInstanceId, data); + if (result.success) { + setShowAddModal(false); + refreshUsers(); + showSuccess('Benutzer hinzugefügt', 'Der Benutzer wurde erfolgreich zur Feature-Instanz hinzugefügt.'); + } else { + showError('Fehler', result.error || 'Fehler beim Hinzufügen des Benutzers'); + } + } finally { + setIsSubmitting(false); + } + }; + + // Handle edit roles submit + const handleEditRoles = async (data: { roleIds: string[] }) => { + if (!selectedMandateId || !selectedInstanceId || !editingUser) return; + setIsSubmitting(true); + try { + const result = await updateInstanceUserRoles( + selectedMandateId, + selectedInstanceId, + editingUser.userId, + data.roleIds + ); + if (result.success) { + setEditingUser(null); + refreshUsers(); + showSuccess('Rollen aktualisiert', 'Die Benutzerrollen wurden erfolgreich aktualisiert.'); + } else { + showError('Fehler', result.error || 'Fehler beim Aktualisieren der Rollen'); + } + } finally { + setIsSubmitting(false); + } + }; + + // Handle remove user + const handleRemoveUser = async (user: FeatureAccessUser) => { + if (!selectedMandateId || !selectedInstanceId) return; + if (window.confirm(`Möchten Sie den Benutzer "${user.username}" wirklich aus dieser Feature-Instanz entfernen?`)) { + const result = await removeUserFromInstance(selectedMandateId, selectedInstanceId, user.userId); + if (result.success) { + refreshUsers(); + showSuccess('Benutzer entfernt', `"${user.username}" wurde aus der Feature-Instanz entfernt.`); + } else { + showError('Fehler', result.error || 'Fehler beim Entfernen des Benutzers'); + } + } + }; + + // Handle edit click + const handleEditClick = (user: FeatureAccessUser) => { + setEditingUser(user); + }; + + // Get mandate name + const getMandateName = (mandate: Mandate) => { + if (typeof mandate.name === 'object') { + return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; + } + return mandate.name || mandate.id; + }; + + // Get feature label + const getFeatureLabel = (code: string) => { + const feature = features.find(f => f.code === code); + if (feature) { + return typeof feature.label === 'object' + ? (feature.label.de || feature.label.en || code) + : (feature.label || code); + } + return code; + }; + + // Get selected instance info + const selectedInstance = useMemo(() => { + return instances.find(i => i.id === selectedInstanceId); + }, [instances, selectedInstanceId]); + + if (error && !selectedMandateId) { + return ( +
+
+ ⚠️ +

Fehler: {error}

+ +
+
+ ); + } + + return ( +
+
+
+

Feature-Benutzer

+

Verwalten Sie Benutzerzugriffe auf Feature-Instanzen

+
+
+ + {/* Selectors */} +
+
+ + +
+ + {selectedMandateId && ( +
+ + +
+ )} + + {selectedInstanceId && ( +
+ + +
+ )} +
+ + {/* Info box when instance is selected */} + {selectedInstance && instanceRoles.length > 0 && ( +
+ + Verfügbare Rollen: + {instanceRoles.map((r, i) => ( + + {i > 0 && ', '} + {r.roleLabel} + + ))} +
+ )} + + {/* Warning if no roles available */} + {selectedInstance && instanceRoles.length === 0 && !usersLoading && ( +
+ ⚠️ + Diese Instanz hat noch keine Rollen. Bitte synchronisieren Sie die Rollen zuerst unter "Feature-Instanzen". +
+ )} + + {/* Content */} + {!selectedMandateId ? ( +
+ +

Kein Mandant ausgewählt

+

+ Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu sehen. +

+
+ ) : !selectedInstanceId ? ( +
+ +

Keine Feature-Instanz ausgewählt

+

+ {instances.length === 0 + ? 'Dieser Mandant hat noch keine Feature-Instanzen.' + : 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'} +

+
+ ) : usersLoading && instanceUsers.length === 0 ? ( +
+
+ Lade Benutzer... +
+ ) : instanceUsers.length === 0 ? ( +
+ +

Keine Benutzer

+

+ Dieser Feature-Instanz sind noch keine Benutzer zugewiesen. +

+ +
+ ) : ( +
+ { + // Find user by FeatureAccess ID to get userId for API call + const user = instanceUsers.find(u => u.id === featureAccessId); + if (user) { + const result = await removeUserFromInstance(selectedMandateId, selectedInstanceId, user.userId); + return result.success; + } + return false; + }, + }} + emptyMessage="Keine Benutzer gefunden" + /> +
+ )} + + {/* Add User Modal */} + {showAddModal && ( +
setShowAddModal(false)}> +
e.stopPropagation()}> +
+

Benutzer zur Feature-Instanz hinzufügen

+ +
+
+ {availableUsers.length === 0 ? ( +

Alle Benutzer haben bereits Zugriff auf diese Feature-Instanz.

+ ) : instanceRoles.length === 0 ? ( +

Diese Feature-Instanz hat keine Rollen. Bitte synchronisieren Sie zuerst die Rollen.

+ ) : ( + setShowAddModal(false)} + submitButtonText="Hinzufügen" + cancelButtonText="Abbrechen" + /> + )} +
+
+
+ )} + + {/* Edit Roles Modal */} + {editingUser && ( +
setEditingUser(null)}> +
e.stopPropagation()}> +
+

Rollen bearbeiten: {editingUser.username}

+ +
+
+ setEditingUser(null)} + submitButtonText="Speichern" + cancelButtonText="Abbrechen" + /> +
+
+
+ )} +
+ ); +}; + +export default AdminFeatureInstanceUsersPage; diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index bbeed51..20e1fcb 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -5,8 +5,8 @@ * Allows creating, viewing, and revoking invitations. */ -import React, { useState, useEffect, useMemo } from 'react'; -import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useInvitations, type Invitation, type InvitationCreate, type PaginationParams } from '../../hooks/useInvitations'; import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; @@ -19,6 +19,7 @@ export const AdminInvitationsPage: React.FC = () => { invitations, loading, error, + pagination, fetchInvitations, createInvitation, revokeInvitation, @@ -353,7 +354,8 @@ export const AdminInvitationsPage: React.FC = () => { ]} onDelete={handleRevokeInvitation} hookData={{ - refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }), + refetch: fetchInvitations, + pagination, }} emptyMessage="Keine Einladungen gefunden" /> diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index a931f45..d6f167e 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -5,8 +5,8 @@ * Allows creating, viewing, editing, and deleting mandate-level roles. */ -import React, { useState, useEffect, useMemo } from 'react'; -import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate } from '../../hooks/useMandateRoles'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type PaginationParams } from '../../hooks/useMandateRoles'; import { useUserMandates, type Mandate } from '../../hooks/useUserMandates'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; @@ -19,6 +19,7 @@ export const AdminMandateRolesPage: React.FC = () => { roles, loading, error, + pagination, fetchRoles, createRole, updateRole, @@ -60,7 +61,16 @@ export const AdminMandateRolesPage: React.FC = () => { } }, [selectedMandateId, fetchRoles]); + // Refetch wrapper that accepts pagination params from FormGeneratorTable + const refetchWithPagination = useCallback(async (paginationParams?: PaginationParams) => { + if (!selectedMandateId) return; + // Pass pagination params to fetchRoles + return fetchRoles(paginationParams || {}); + }, [selectedMandateId, fetchRoles]); + // Filter roles based on selection and add scopeType field + // Note: This client-side filtering is still needed for the roleFilter dropdown + // Backend pagination handles page/sort/search, but roleFilter is UI-specific const filteredRoles = useMemo(() => { if (!selectedMandateId) return []; @@ -433,7 +443,8 @@ export const AdminMandateRolesPage: React.FC = () => { ]} onDelete={handleDeleteRole} hookData={{ - refetch: () => fetchRoles(selectedMandateId), + refetch: refetchWithPagination, + pagination: pagination, handleDelete: handleDeleteRole, }} emptyMessage="Keine Rollen gefunden" diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx index f517963..cfa5b8d 100644 --- a/src/pages/admin/AdminUserMandatesPage.tsx +++ b/src/pages/admin/AdminUserMandatesPage.tsx @@ -5,8 +5,8 @@ * Allows assigning users to mandates and managing their roles within mandates. */ -import React, { useState, useEffect, useMemo } from 'react'; -import { useUserMandates, type MandateUser, type Mandate, type Role } from '../../hooks/useUserMandates'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useUserMandates, type MandateUser, type Mandate, type Role, type PaginationParams } from '../../hooks/useUserMandates'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaUsers, FaBuilding } from 'react-icons/fa'; @@ -18,6 +18,7 @@ export const AdminUserMandatesPage: React.FC = () => { users, loading, error, + pagination, fetchMandateUsers, addUserToMandate, removeUserFromMandate, @@ -356,17 +357,18 @@ export const AdminUserMandatesPage: React.FC = () => { ]} onDelete={handleRemoveUser} hookData={{ - refetch: () => fetchMandateUsers(selectedMandateId), - handleDelete: async (userId: string) => { - const user = users.find(u => u.userId === userId); + refetch: fetchMandateUsers, + pagination, + handleDelete: async (userMandateId: string) => { + // Find user by UserMandate ID to get userId for API call + const user = users.find(u => u.id === userMandateId); if (user) { - const result = await removeUserFromMandate(selectedMandateId, userId); + const result = await removeUserFromMandate(selectedMandateId, user.userId); return result.success; } return false; }, }} - idField="userId" emptyMessage="Keine Mitglieder gefunden" />
diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index f896a5d..67215b4 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -10,4 +10,5 @@ export { AdminRolesPage } from './AdminRolesPage'; export { AdminUserMandatesPage } from './AdminUserMandatesPage'; export { AdminFeatureAccessPage } from './AdminFeatureAccessPage'; export { AdminInvitationsPage } from './AdminInvitationsPage'; -export { AdminMandateRolesPage } from './AdminMandateRolesPage'; \ No newline at end of file +export { AdminMandateRolesPage } from './AdminMandateRolesPage'; +export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage'; \ No newline at end of file