fixed uid mapping to id

This commit is contained in:
ValueOn AG 2026-01-21 10:59:34 +01:00
parent 7f07a55c91
commit 537b624c59
24 changed files with 1385 additions and 142 deletions

View file

@ -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 (
<LanguageProvider>
<AuthProvider>
<Router>
<Routes>
<ToastProvider>
<Router>
<Routes>
{/* ================================================== */}
{/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */}
{/* ================================================== */}
@ -123,6 +125,7 @@ function App() {
<Route path="roles" element={<AdminRolesPage />} />
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
<Route path="feature-instances" element={<AdminFeatureAccessPage />} />
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
</Route>
@ -136,8 +139,9 @@ function App() {
<MainLayout />
</ProtectedRoute>
} />
</Routes>
</Router>
</Routes>
</Router>
</ToastProvider>
</AuthProvider>
</LanguageProvider>
);

View file

@ -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 */

View file

@ -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: <FaCubes />,
path: '/admin/feature-instances',
},
{
id: 'admin-feature-users',
label: 'Feature-Benutzer',
icon: <FaUsersCog />,
path: '/admin/feature-users',
},
],
});
}

View file

@ -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%);
}

View file

@ -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 <FaCheckCircle />;
case 'error':
return <FaExclamationCircle />;
case 'warning':
return <FaExclamationTriangle />;
case 'info':
return <FaInfoCircle />;
}
};
export const Toast: React.FC<ToastProps> = ({ toast, onClose }) => {
return (
<motion.div
className={`${styles.toast} ${styles[toast.type]}`}
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.9 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
layout
>
<div className={`${styles.icon} ${styles[toast.type]}`}>
{_getIcon(toast.type)}
</div>
<div className={styles.content}>
<p className={styles.title}>{toast.title}</p>
{toast.message && <p className={styles.message}>{toast.message}</p>}
</div>
<button
className={styles.closeButton}
onClick={() => onClose(toast.id)}
aria-label="Schließen"
>
<FaTimes />
</button>
</motion.div>
);
};
interface ToastContainerProps {
toasts: ToastData[];
onClose: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onClose }) => {
return (
<div className={styles.toastContainer}>
<AnimatePresence mode="popLayout">
{toasts.map((toast) => (
<Toast key={toast.id} toast={toast} onClose={onClose} />
))}
</AnimatePresence>
</div>
);
};

View file

@ -0,0 +1,2 @@
export { Toast, ToastContainer } from './Toast';
export type { ToastType, ToastData } from './Toast';

View file

@ -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';

View file

@ -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<ToastContextValue | null>(null);
const DEFAULT_DURATION = 5000;
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastData[]>([]);
const timeoutRefs = useRef<Map<string, NodeJS.Timeout>>(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 (
<ToastContext.Provider value={{ showToast, showSuccess, showError, showWarning, showInfo, closeToast }}>
{children}
<ToastContainer toasts={toasts} onClose={closeToast} />
</ToastContext.Provider>
);
};
export const useToast = (): ToastContextValue => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};

View file

@ -2357,8 +2357,6 @@ const PageRenderer: React.FC<PageRendererProps> = ({
</div>
</div>
{/* Message Overlay Component */}
{hookData?.MessageOverlayComponent && <hookData.MessageOverlayComponent />}
</div>
</DragDropOverlay>
);

View file

@ -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,

View file

@ -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<string, SettingsFieldConfig[]>; // Field definitions per sectionId

View file

@ -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<string, any>;
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<FeatureInstance[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [instancesPagination, setInstancesPagination] = useState<PaginationMetadata | null>(null);
// Store current context for refetch
const currentMandateIdRef = useRef<string>('');
const currentFeatureCodeRef = useRef<string | undefined>(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<FeatureInstance[]> => {
const fetchInstances = useCallback(async (
mandateIdOrPagination?: string | PaginationParams,
featureCode?: string
): Promise<FeatureInstance[]> => {
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<FeatureAccessUser[]> => {
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<FeatureInstanceRole[]> => {
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,
};
}

View file

@ -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<Set<string>>(new Set());
const [previewError, setPreviewError] = useState<string | null>(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
};
}

View file

@ -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<string, any>;
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<Invitation[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
// Store current context for refetch
const currentMandateIdRef = useRef<string>('');
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<Invitation[]> => {
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,

View file

@ -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<string, any>;
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<Role[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
// Store current mandateId for refetch
const currentMandateIdRef = useRef<string | undefined>();
/**
* 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<Role[]> => {
const fetchRoles = useCallback(async (
mandateIdOrParams?: string | PaginationParams
): Promise<Role[]> => {
setLoading(true);
setError(null);
try {
const headers: Record<string, string> = {};
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<string, string> = {};
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,

View file

@ -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<string, any>;
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<MandateUser[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
// Store current mandate for refetch
const currentMandateIdRef = useRef<string>('');
/**
* Fetch all users in a specific mandate
* Fetch all users in a specific mandate with optional pagination
*/
const fetchMandateUsers = useCallback(async (mandateId: string): Promise<MandateUser[]> => {
const fetchMandateUsers = useCallback(async (
mandateIdOrPagination?: string | PaginationParams
): Promise<MandateUser[]> => {
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,

View file

@ -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<string | null>(null);
const [deleteFileError, setDeleteFileError] = useState<string | null>(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
};
}

View file

@ -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;

View file

@ -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<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [, setIsSubmitting] = useState(false);
const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
@ -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"

View file

@ -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<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [selectedInstanceId, setSelectedInstanceId] = useState<string>('');
const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]);
const [instanceRoles, setInstanceRoles] = useState<FeatureInstanceRole[]>([]);
const [allUsers, setAllUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState<FeatureAccessUser | null>(null);
const [, setIsSubmitting] = useState(false);
const [usersLoading, setUsersLoading] = useState(false);
const [usersPagination, setUsersPagination] = useState<PaginationMetadata | null>(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 (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature-Benutzer</h1>
<p className={styles.pageSubtitle}>Verwalten Sie Benutzerzugriffe auf Feature-Instanzen</p>
</div>
</div>
{/* Selectors */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
Mandant:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">-- Mandant wählen --</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
</option>
))}
</select>
</div>
{selectedMandateId && (
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Feature-Instanz:
</label>
<select
className={styles.filterSelect}
value={selectedInstanceId}
onChange={(e) => setSelectedInstanceId(e.target.value)}
disabled={loading || instances.length === 0}
>
<option value="">-- Instanz wählen --</option>
{instances.map(i => (
<option key={i.id} value={i.id}>
{i.label} ({getFeatureLabel(i.featureCode)})
</option>
))}
</select>
</div>
)}
{selectedInstanceId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refreshUsers()}
disabled={usersLoading}
>
<FaSync className={usersLoading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0 || instanceRoles.length === 0}
>
<FaPlus /> Benutzer hinzufügen
</button>
</div>
)}
</div>
{/* Info box when instance is selected */}
{selectedInstance && instanceRoles.length > 0 && (
<div className={styles.infoBox}>
<FaCube style={{ marginRight: 8 }} />
<span>Verfügbare Rollen: </span>
{instanceRoles.map((r, i) => (
<span key={r.id}>
{i > 0 && ', '}
<strong>{r.roleLabel}</strong>
</span>
))}
</div>
)}
{/* Warning if no roles available */}
{selectedInstance && instanceRoles.length === 0 && !usersLoading && (
<div className={styles.warningBox || styles.infoBox}>
<span> </span>
<span>Diese Instanz hat noch keine Rollen. Bitte synchronisieren Sie die Rollen zuerst unter "Feature-Instanzen".</span>
</div>
)}
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu sehen.
</p>
</div>
) : !selectedInstanceId ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Feature-Instanz ausgewählt</h3>
<p className={styles.emptyDescription}>
{instances.length === 0
? 'Dieser Mandant hat noch keine Feature-Instanzen.'
: 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'}
</p>
</div>
) : usersLoading && instanceUsers.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Benutzer...</span>
</div>
) : instanceUsers.length === 0 ? (
<div className={styles.emptyState}>
<FaUsers className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Benutzer</h3>
<p className={styles.emptyDescription}>
Dieser Feature-Instanz sind noch keine Benutzer zugewiesen.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0 || instanceRoles.length === 0}
>
<FaPlus /> Ersten Benutzer hinzufügen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={instanceUsers}
columns={columns}
loading={usersLoading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rollen bearbeiten',
},
{
type: 'delete' as const,
title: 'Aus Instanz entfernen',
}
]}
onDelete={handleRemoveUser}
hookData={{
refetch: refreshUsers,
pagination: usersPagination,
handleDelete: async (featureAccessId: string) => {
// 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"
/>
</div>
)}
{/* Add User Modal */}
{showAddModal && (
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Benutzer zur Feature-Instanz hinzufügen</h2>
<button
className={styles.modalClose}
onClick={() => setShowAddModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{availableUsers.length === 0 ? (
<p>Alle Benutzer haben bereits Zugriff auf diese Feature-Instanz.</p>
) : instanceRoles.length === 0 ? (
<p>Diese Feature-Instanz hat keine Rollen. Bitte synchronisieren Sie zuerst die Rollen.</p>
) : (
<FormGeneratorForm
attributes={addUserFields}
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText="Hinzufügen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Roles Modal */}
{editingUser && (
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Rollen bearbeiten: {editingUser.username}</h2>
<button
className={styles.modalClose}
onClick={() => setEditingUser(null)}
>
</button>
</div>
<div className={styles.modalContent}>
<FormGeneratorForm
attributes={editRolesFields}
data={{ roleIds: editingUser.roleIds }}
mode="edit"
onSubmit={handleEditRoles}
onCancel={() => setEditingUser(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
</div>
</div>
</div>
)}
</div>
);
};
export default AdminFeatureInstanceUsersPage;

View file

@ -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"
/>

View file

@ -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"

View file

@ -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"
/>
</div>

View file

@ -10,4 +10,5 @@ export { AdminRolesPage } from './AdminRolesPage';
export { AdminUserMandatesPage } from './AdminUserMandatesPage';
export { AdminFeatureAccessPage } from './AdminFeatureAccessPage';
export { AdminInvitationsPage } from './AdminInvitationsPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';