serverside filter and sort for form generic

This commit is contained in:
ValueOn AG 2026-01-21 15:57:20 +01:00
parent 537b624c59
commit d3873223f5
10 changed files with 625 additions and 414 deletions

View file

@ -37,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, AdminFeatureInstanceUsersPage } from './pages/admin';
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage } from './pages/admin';
function App() {
// Load saved theme preference and set app name on app mount
@ -122,9 +122,9 @@ function App() {
<Route path="admin">
<Route path="mandates" element={<AdminMandatesPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="roles" element={<AdminRolesPage />} />
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
<Route path="feature-instances" element={<AdminFeatureAccessPage />} />
<Route path="feature-roles" element={<AdminFeatureRolesPage />} />
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />

View file

@ -87,8 +87,9 @@ export function FormGeneratorControls({
onPageChange,
onPageSizeChange,
supportsBackendPagination = false,
hookData
hookData: _hookData // Reserved for future use
}: FormGeneratorControlsProps) {
void _hookData; // Suppress unused variable warning
const { t } = useLanguage();
// Check if all items are selected
@ -251,11 +252,9 @@ export function FormGeneratorControls({
»»
</button>
{/* Total items count */}
{/* Total items count - always show actual displayed data length */}
<span className={styles.paginationInfo}>
({hookData?.pagination?.totalItems != null
? hookData.pagination.totalItems.toString()
: (loading ? '...' : displayData.length.toString())} {t('formgen.pagination.items', 'items')})
({loading ? '...' : displayData.length.toString()} {t('formgen.pagination.items', 'items')})
</span>
</div>
)}

View file

@ -106,7 +106,8 @@
.th {
position: sticky;
top: 0;
/* Due to scaleY(-1) transform on container, bottom: 0 acts as top: 0 */
bottom: 0;
background: var(--color-bg);
padding: 10px 16px;
text-align: left;
@ -119,6 +120,8 @@
user-select: none;
z-index: 10;
overflow: visible;
/* Add shadow to visually separate from scrolled content */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.th.actionsColumn {
@ -336,6 +339,13 @@
position: relative;
}
/* Selection column header sticky */
thead .selectColumn {
position: sticky;
bottom: 0;
z-index: 10;
}
/* Selection Column border only on body cells, not header */
tbody .selectColumn {
border-top: 1px solid var(--color-primary);
@ -384,6 +394,13 @@ tbody .selectColumn {
position: relative;
}
/* Actions column header sticky */
thead .actionsColumn {
position: sticky;
bottom: 0;
z-index: 10;
}
/* Actions Column border only on body cells, not header */
tbody .actionsColumn {
border-top: 1px solid var(--color-primary);

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, FaUsersCog } from 'react-icons/fa';
import { FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag, FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube } from 'react-icons/fa';
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
import styles from './MandateNavigation.module.css';
@ -207,12 +207,6 @@ export const MandateNavigation: React.FC = () => {
icon: <FaEnvelopeOpenText />,
path: '/admin/invitations',
},
{
id: 'admin-roles',
label: 'Globale Rollen',
icon: <FaUserShield />,
path: '/admin/roles',
},
{
id: 'admin-mandates',
label: 'Mandanten',
@ -221,7 +215,7 @@ export const MandateNavigation: React.FC = () => {
},
{
id: 'admin-mandate-roles',
label: 'Mandanten-Rollen',
label: 'Rollen',
icon: <FaKey />,
path: '/admin/mandate-roles',
},
@ -231,6 +225,12 @@ export const MandateNavigation: React.FC = () => {
icon: <FaUserTag />,
path: '/admin/user-mandates',
},
{
id: 'admin-feature-roles',
label: 'Feature-Rollen',
icon: <FaCube />,
path: '/admin/feature-roles',
},
{
id: 'admin-feature-instances',
label: 'Feature-Instanzen',

View file

@ -42,6 +42,7 @@ export interface PaginationParams {
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
scopeFilter?: 'all' | 'mandate' | 'global'; // Backend filter for role scope
}
export interface PaginationMetadata {
@ -61,14 +62,16 @@ export function useMandateRoles() {
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
// Store current mandateId for refetch
const currentMandateIdRef = useRef<string | undefined>();
const currentMandateIdRef = useRef<string | undefined>(undefined);
/**
* Fetch all roles with pagination support
* @param mandateIdOrParams - Either a mandateId string (backward compatible) or pagination params
* @param additionalParams - Additional parameters like scopeFilter (when first param is mandateId)
*/
const fetchRoles = useCallback(async (
mandateIdOrParams?: string | PaginationParams
mandateIdOrParams?: string | PaginationParams,
additionalParams?: PaginationParams
): Promise<Role[]> => {
setLoading(true);
setError(null);
@ -77,27 +80,43 @@ export function useMandateRoles() {
const headers: Record<string, string> = {};
let paginationParams: PaginationParams = {};
let mandateId: string | undefined;
let scopeFilter: string | undefined;
// Handle backward compatibility: first param can be mandateId string or pagination object
if (typeof mandateIdOrParams === 'string') {
mandateId = mandateIdOrParams;
currentMandateIdRef.current = mandateId;
// If additional params provided, use them
if (additionalParams) {
paginationParams = additionalParams;
scopeFilter = additionalParams.scopeFilter;
}
} else if (mandateIdOrParams && typeof mandateIdOrParams === 'object') {
paginationParams = mandateIdOrParams;
mandateId = currentMandateIdRef.current;
scopeFilter = mandateIdOrParams.scopeFilter;
}
if (mandateId) {
headers['X-Mandate-Id'] = mandateId;
}
// Build query params for pagination
// Build query params for pagination (exclude scopeFilter from pagination JSON)
const { scopeFilter: _, ...paginationWithoutScope } = paginationParams;
const queryParams: Record<string, string> = {};
if (Object.keys(paginationParams).length > 0) {
queryParams.pagination = JSON.stringify(paginationParams);
if (Object.keys(paginationWithoutScope).length > 0) {
queryParams.pagination = JSON.stringify(paginationWithoutScope);
}
// Include templates by default for mandate roles view
queryParams.includeTemplates = 'true';
// Include mandate-specific roles for the selected mandate
if (mandateId) {
queryParams.mandateId = mandateId;
}
// Include scopeFilter as separate query parameter
if (scopeFilter) {
queryParams.scopeFilter = scopeFilter;
}
const response = await api.get('/api/rbac/roles', {
headers,
@ -116,11 +135,7 @@ export function useMandateRoles() {
data = response.data;
}
// Filter to only show roles for this mandate (or global roles)
// 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);
}
// No client-side filtering needed - backend already filters
setRoles(data);
setPagination(paginationMeta);
@ -186,8 +201,12 @@ export function useMandateRoles() {
setError(null);
try {
const response = await api.put(`/api/rbac/roles/${roleId}`, data);
// Optimistically update local state
setRoles(prev => prev.map(r => r.id === roleId ? { ...r, ...data } : r));
// Optimistically update local state (convert null to undefined for mandateId)
const updateData = {
...data,
mandateId: data.mandateId === null ? undefined : data.mandateId
};
setRoles(prev => prev.map(r => r.id === roleId ? { ...r, ...updateData } : r));
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update role';

View file

@ -0,0 +1,442 @@
/**
* AdminFeatureRolesPage
*
* Admin page for managing FEATURE TEMPLATE ROLES.
* These are roles that are copied to new feature instances.
*
* According to admin_ui_concept.md:
* - Filter: featureCode!=null AND mandateId=null AND featureInstanceId=null
* - View: Per feature (dropdown selection)
* - Actions: Create feature role, edit description, manage AccessRules, delete role
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUserShield, FaCube } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
interface Feature {
id: string;
featureCode: string;
name: string | { [key: string]: string };
description?: string | { [key: string]: string };
}
interface FeatureRole {
id: string;
roleLabel: string;
description?: { [key: string]: string };
featureCode: string;
mandateId?: string | null;
featureInstanceId?: string | null;
isSystemRole?: boolean;
}
export const AdminFeatureRolesPage: React.FC = () => {
// State
const [features, setFeatures] = useState<Feature[]>([]);
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
const [roles, setRoles] = useState<FeatureRole[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<FeatureRole | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Load features on mount
useEffect(() => {
const loadFeatures = async () => {
try {
const response = await api.get('/api/features');
const featureList = response.data?.items || response.data || [];
setFeatures(Array.isArray(featureList) ? featureList : []);
// Auto-select first feature if available
if (featureList.length > 0 && !selectedFeatureCode) {
setSelectedFeatureCode(featureList[0].featureCode);
}
} catch (err: any) {
console.error('Error loading features:', err);
setError('Fehler beim Laden der Features');
}
};
loadFeatures();
}, []);
// Load roles when feature changes
const fetchRoles = useCallback(async () => {
if (!selectedFeatureCode) {
setRoles([]);
return;
}
setLoading(true);
setError(null);
try {
const response = await api.get(`/api/features/templates/roles`, {
params: { featureCode: selectedFeatureCode }
});
const roleList = response.data || [];
setRoles(Array.isArray(roleList) ? roleList : []);
} catch (err: any) {
console.error('Error loading feature roles:', err);
setError('Fehler beim Laden der Feature-Rollen');
setRoles([]);
} finally {
setLoading(false);
}
}, [selectedFeatureCode]);
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
// Get text from multilingual object
const getTextValue = (value: string | { [key: string]: string } | undefined): string => {
if (!value) return '-';
if (typeof value === 'string') return value;
return value.de || value.en || Object.values(value)[0] || '-';
};
// Table columns
const columns = useMemo(() => [
{
key: 'roleLabel',
label: 'Rollen-Label',
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
width: 180
},
{
key: 'description',
label: 'Beschreibung',
type: 'string' as const,
sortable: false,
width: 300,
formatter: (value: string | { [key: string]: string }) => getTextValue(value)
},
{
key: 'featureCode',
label: 'Feature',
type: 'string' as const,
sortable: true,
filterable: true,
width: 120,
formatter: (value: string) => (
<span className={styles.badge} style={{ background: 'var(--primary-color, #4a5568)', color: 'white' }}>
<FaCube style={{ marginRight: 4 }} /> {value}
</span>
)
},
], []);
// Form attributes for create
const createFields: AttributeDefinition[] = useMemo(() => {
const fields: AttributeDefinition[] = [
{
name: 'roleLabel',
label: 'Rollen-Label',
type: 'string',
required: true,
description: 'Eindeutiger Bezeichner der Rolle (z.B. trustee-admin)'
},
{
name: 'description',
label: 'Beschreibung',
type: 'multilingual',
required: false,
description: 'Mehrsprachige Beschreibung der Rolle'
}
];
return fields;
}, []);
// Form attributes for edit
const editFields: AttributeDefinition[] = useMemo(() => {
return [
{
name: 'roleLabel',
label: 'Rollen-Label',
type: 'string',
required: true,
readonly: true, // Label should not be changed after creation
description: 'Eindeutiger Bezeichner der Rolle'
},
{
name: 'description',
label: 'Beschreibung',
type: 'multilingual',
required: false,
description: 'Mehrsprachige Beschreibung der Rolle'
}
];
}, []);
// Handle create role
const handleCreateRole = async (data: { roleLabel: string; description?: { [key: string]: string } }) => {
if (!selectedFeatureCode) return;
setIsSubmitting(true);
try {
const params = new URLSearchParams();
params.append('roleLabel', data.roleLabel);
params.append('featureCode', selectedFeatureCode);
await api.post(`/api/features/templates/roles?${params.toString()}`, data.description || {});
setShowCreateModal(false);
await fetchRoles();
} catch (err: any) {
console.error('Error creating role:', err);
alert(err.response?.data?.detail || 'Fehler beim Erstellen der Rolle');
} finally {
setIsSubmitting(false);
}
};
// Handle edit role
const handleEditRole = async (data: { roleLabel: string; description?: { [key: string]: string } }) => {
if (!editingRole) return;
setIsSubmitting(true);
try {
await api.put(`/api/rbac/roles/${editingRole.id}`, {
description: data.description
});
setEditingRole(null);
await fetchRoles();
} catch (err: any) {
console.error('Error updating role:', err);
alert(err.response?.data?.detail || 'Fehler beim Aktualisieren der Rolle');
} finally {
setIsSubmitting(false);
}
};
// Handle delete role
const handleDeleteRole = async (role: FeatureRole) => {
if (window.confirm(`Möchten Sie die Rolle "${role.roleLabel}" wirklich löschen?`)) {
try {
await api.delete(`/api/rbac/roles/${role.id}`);
await fetchRoles();
} catch (err: any) {
console.error('Error deleting role:', err);
alert(err.response?.data?.detail || 'Fehler beim Löschen der Rolle');
}
}
};
// Handle edit click
const handleEditClick = (role: FeatureRole) => {
setEditingRole(role);
};
// Get feature name
const getFeatureName = (feature: Feature) => getTextValue(feature.name);
if (error && !selectedFeatureCode) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{error}</p>
<button className={styles.secondaryButton} onClick={() => window.location.reload()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature-Rollen</h1>
<p className={styles.pageSubtitle}>Template-Rollen für Feature-Instanzen verwalten</p>
</div>
</div>
{/* Feature Selector */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Feature:
</label>
<select
className={styles.filterSelect}
value={selectedFeatureCode}
onChange={(e) => setSelectedFeatureCode(e.target.value)}
>
<option value="">-- Feature wählen --</option>
{features.map(f => (
<option key={f.id} value={f.featureCode}>
{getFeatureName(f)} ({f.featureCode})
</option>
))}
</select>
</div>
{selectedFeatureCode && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Feature-Rolle
</button>
</div>
)}
</div>
{/* Info Box */}
{selectedFeatureCode && (
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>Feature-Template-Rollen</strong> werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert.
Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus.
</span>
</div>
)}
{/* Content */}
{!selectedFeatureCode ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Feature ausgewählt</h3>
<p className={styles.emptyDescription}>
Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.
</p>
</div>
) : loading && roles.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Feature-Rollen...</span>
</div>
) : roles.length === 0 ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
<p className={styles.emptyDescription}>
Es gibt noch keine Template-Rollen für dieses Feature.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Rolle erstellen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rolle bearbeiten',
},
{
type: 'delete' as const,
title: 'Rolle löschen',
}
]}
onDelete={handleDeleteRole}
hookData={{
refetch: fetchRoles,
handleDelete: handleDeleteRole,
}}
emptyMessage="Keine Feature-Rollen gefunden"
/>
</div>
)}
{/* Create Role Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Feature-Rolle erstellen</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{selectedFeatureCode}</strong></span>
</div>
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? 'Erstelle...' : 'Rolle erstellen'}
cancelButtonText="Abbrechen"
/>
</div>
</div>
</div>
)}
{/* Edit Role Modal */}
{editingRole && (
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Feature-Rolle bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingRole(null)}
>
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{editingRole.featureCode}</strong></span>
</div>
<FormGeneratorForm
attributes={editFields}
data={editingRole}
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
</div>
</div>
</div>
)}
</div>
);
};
export default AdminFeatureRolesPage;

View file

@ -1,16 +1,24 @@
/**
* AdminMandateRolesPage
*
* Admin page for managing roles within a specific mandate.
* Allows creating, viewing, editing, and deleting mandate-level roles.
* Admin page for managing ALL ROLES (system + global + mandate-specific).
* Consolidated view replacing separate System-Roles page.
*
* Shows:
* - System roles (admin, user, viewer) - read-only, cannot be deleted
* - Global roles (mandateId=null) - CRUD available
* - Mandate-specific roles (mandateId=xyz) - CRUD available
* - Feature-template roles are managed in AdminFeatureRolesPage
*
* ALL filtering, sorting, and pagination is handled by the backend.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } 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';
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaCube } from 'react-icons/fa';
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
@ -34,8 +42,12 @@ export const AdminMandateRolesPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [roleFilter, setRoleFilter] = useState<'all' | 'mandate' | 'global'>('all');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Store current filter state for refetch
const currentScopeFilterRef = useRef(scopeFilter);
currentScopeFilterRef.current = scopeFilter;
// Load mandates and attributes on mount
useEffect(() => {
@ -54,47 +66,24 @@ export const AdminMandateRolesPage: React.FC = () => {
}).catch(() => setBackendAttributes([]));
}, [fetchMandates]);
// Load roles when mandate changes
// Load roles when mandate or scopeFilter changes
useEffect(() => {
if (selectedMandateId) {
fetchRoles(selectedMandateId);
fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, fetchRoles]);
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Refetch wrapper that accepts pagination params from FormGeneratorTable
const refetchWithPagination = useCallback(async (paginationParams?: PaginationParams) => {
// and includes the current mandateId and scopeFilter
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
if (!selectedMandateId) return;
// Pass pagination params to fetchRoles
return fetchRoles(paginationParams || {});
// Merge pagination params with current filter state
return fetchRoles(selectedMandateId, {
...paginationParams,
scopeFilter: currentScopeFilterRef.current
});
}, [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 [];
return roles
.filter(role => {
// Don't show feature-instance level roles here
if (role.featureInstanceId) return false;
switch (roleFilter) {
case 'mandate':
return role.mandateId === selectedMandateId;
case 'global':
return !role.mandateId;
default:
return !role.mandateId || role.mandateId === selectedMandateId;
}
})
.map(role => ({
...role,
// Computed field for table display - not an ID/boolean type
scopeType: role.isSystemRole ? 'system' : (role.mandateId ? 'mandate' : 'global')
}));
}, [roles, selectedMandateId, roleFilter]);
// Get description text
const getDescriptionText = (desc: string | { [key: string]: string } | undefined) => {
if (!desc) return '-';
@ -102,7 +91,7 @@ export const AdminMandateRolesPage: React.FC = () => {
return desc.de || desc.en || Object.values(desc)[0] || '-';
};
// Table columns
// Table columns - scopeType is now a backend-computed field
const columns = useMemo(() => [
{
key: 'roleLabel',
@ -118,16 +107,17 @@ export const AdminMandateRolesPage: React.FC = () => {
label: 'Beschreibung',
type: 'string' as const,
sortable: false,
filterable: false,
width: 250,
formatter: (value: string | { [key: string]: string }) => getDescriptionText(value)
},
{
key: 'scopeType',
label: 'Typ',
label: 'Geltungsbereich',
type: 'string' as const,
sortable: true,
filterable: true,
width: 120,
width: 140,
formatter: (value: string) => {
if (value === 'system') {
return (
@ -150,24 +140,11 @@ export const AdminMandateRolesPage: React.FC = () => {
);
}
},
{
key: 'featureCode',
label: 'Feature',
type: 'string' as const,
sortable: true,
filterable: true,
width: 120,
formatter: (value: string) => value ? (
<span className={styles.badge} style={{ background: 'var(--bg-secondary)' }}>
<FaCube style={{ marginRight: 4 }} /> {value}
</span>
) : '-'
},
], []);
// Form attributes from backend - for create form
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole'];
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
const fields = backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
@ -191,51 +168,52 @@ export const AdminMandateRolesPage: React.FC = () => {
}, [backendAttributes]);
// Form attributes from backend - for edit form
// NOTE: mandateId/featureInstanceId/featureCode are IMMUTABLE - only description can be edited
const editFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole'];
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
const fields = backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({
...attr,
// Mark roleLabel as readonly for system roles
readonly: attr.name === 'roleLabel' && editingRole?.isSystemRole ? true : attr.readonly,
// Mark roleLabel as readonly (cannot change after creation)
readonly: attr.name === 'roleLabel' ? true : attr.readonly,
})) as AttributeDefinition[];
// Add scope field for mandate/global selection (only if not system role)
if (fields.length > 0 && !editingRole?.isSystemRole) {
fields.push({
name: 'scope',
label: 'Geltungsbereich',
type: 'enum' as any,
required: true,
options: [
{ value: 'mandate', label: 'Nur dieser Mandant' },
{ value: 'global', label: 'Global (alle Mandanten)' },
]
});
}
// No scope field for edit - context is immutable!
return fields;
}, [backendAttributes, editingRole]);
}, [backendAttributes]);
// Handle create role
const handleCreateRole = async (data: { roleLabel: string; description?: string; scope: 'mandate' | 'global' }) => {
const handleCreateRole = async (data: { roleLabel: string; description?: string | { [key: string]: string }; scope: 'mandate' | 'global' }) => {
if (!selectedMandateId) return;
setIsSubmitting(true);
try {
// Ensure description is always a multilingual object
let description: { [key: string]: string } = {};
if (typeof data.description === 'string') {
description = { de: data.description, en: data.description };
} else if (data.description && typeof data.description === 'object') {
description = data.description;
}
const roleData: RoleCreate = {
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
description: data.description,
description: description,
mandateId: data.scope === 'mandate' ? selectedMandateId : undefined
};
const result = await createRole(roleData, selectedMandateId);
if (result.success) {
setShowCreateModal(false);
fetchRoles(selectedMandateId);
await fetchRoles(selectedMandateId, { scopeFilter });
} else {
alert(result.error || 'Fehler beim Erstellen der Rolle');
}
} catch (err: any) {
console.error('Create role error:', err);
alert(err.message || 'Fehler beim Erstellen der Rolle');
} finally {
setIsSubmitting(false);
}
@ -246,23 +224,35 @@ export const AdminMandateRolesPage: React.FC = () => {
if (!editingRole) return;
setIsSubmitting(true);
try {
// Convert scope to mandateId
// Ensure description is always a multilingual object
let description: { [key: string]: string } | undefined;
if (typeof data.description === 'string') {
description = { de: data.description, en: data.description };
} else if (data.description && typeof data.description === 'object') {
description = data.description as { [key: string]: string };
}
// Convert scope to mandateId - NOTE: Context fields are IMMUTABLE per concept!
// We should not be changing mandateId after creation
const updateData: RoleUpdate = {
...data,
mandateId: data.scope === 'mandate' ? selectedMandateId : null,
roleLabel: data.roleLabel,
description: description,
// mandateId is immutable - don't include in update
};
// Remove scope field as it's not part of the model
delete (updateData as any).scope;
const result = await updateRole(editingRole.id, updateData);
if (result.success) {
setEditingRole(null);
if (selectedMandateId) {
fetchRoles(selectedMandateId);
await fetchRoles(selectedMandateId, { scopeFilter });
}
} else {
alert(result.error || 'Fehler beim Aktualisieren der Rolle');
}
} catch (err: any) {
console.error('Update role error:', err);
alert(err.message || 'Fehler beim Aktualisieren der Rolle');
} finally {
setIsSubmitting(false);
}
@ -277,7 +267,10 @@ export const AdminMandateRolesPage: React.FC = () => {
if (window.confirm(`Möchten Sie die Rolle "${role.roleLabel}" wirklich löschen?`)) {
const result = await deleteRole(role.id);
if (!result.success) {
if (result.success) {
// Refetch to update the list
await fetchRoles(selectedMandateId, { scopeFilter });
} else {
alert(result.error || 'Fehler beim Löschen der Rolle');
}
}
@ -314,8 +307,8 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Mandanten-Rollen</h1>
<p className={styles.pageSubtitle}>Verwalten Sie Rollen innerhalb eines Mandanten</p>
<h1 className={styles.pageTitle}>Rollen</h1>
<p className={styles.pageSubtitle}>Verwalten Sie System-, globale und mandantenspezifische Rollen</p>
</div>
</div>
@ -344,8 +337,8 @@ export const AdminMandateRolesPage: React.FC = () => {
<label className={styles.filterLabel}>Filter:</label>
<select
className={styles.filterSelect}
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value as 'all' | 'mandate' | 'global')}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
style={{ minWidth: 150 }}
>
<option value="all">Alle Rollen</option>
@ -358,7 +351,7 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles(selectedMandateId)}
onClick={() => fetchRoles(selectedMandateId, { scopeFilter })}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
@ -378,9 +371,9 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>Globale Rollen</strong> gelten für alle Mandanten.
<strong>System-Rollen</strong> (admin, user, viewer) können nicht bearbeitet oder gelöscht werden.
<strong> Globale Rollen</strong> gelten für alle Mandanten.
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten.
<strong> System-Rollen</strong> (sysadmin) können nicht bearbeitet werden.
</span>
</div>
)}
@ -394,19 +387,19 @@ export const AdminMandateRolesPage: React.FC = () => {
Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.
</p>
</div>
) : loading && filteredRoles.length === 0 ? (
) : loading && roles.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Rollen...</span>
</div>
) : filteredRoles.length === 0 ? (
) : roles.length === 0 ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
<p className={styles.emptyDescription}>
{roleFilter === 'mandate'
{scopeFilter === 'mandate'
? 'Es gibt noch keine mandantenspezifischen Rollen.'
: roleFilter === 'global'
: scopeFilter === 'global'
? 'Es gibt noch keine globalen Rollen.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
</p>
@ -420,7 +413,7 @@ export const AdminMandateRolesPage: React.FC = () => {
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={filteredRoles}
data={roles}
columns={columns}
loading={loading}
pagination={true}
@ -434,6 +427,7 @@ export const AdminMandateRolesPage: React.FC = () => {
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rolle bearbeiten',
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: 'System-Rollen können nicht bearbeitet werden' } : false
},
{
type: 'delete' as const,
@ -443,7 +437,7 @@ export const AdminMandateRolesPage: React.FC = () => {
]}
onDelete={handleDeleteRole}
hookData={{
refetch: refetchWithPagination,
refetch: refetchWithParams,
pagination: pagination,
handleDelete: handleDeleteRole,
}}
@ -477,7 +471,7 @@ export const AdminMandateRolesPage: React.FC = () => {
mode="create"
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Rolle erstellen"
submitButtonText={isSubmitting ? 'Erstelle...' : 'Rolle erstellen'}
cancelButtonText="Abbrechen"
/>
)}
@ -507,22 +501,20 @@ export const AdminMandateRolesPage: React.FC = () => {
</div>
) : (
<>
{editingRole.isSystemRole && (
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaUserShield style={{ marginRight: 8 }} />
<span>System-Rollen können nur eingeschränkt bearbeitet werden.</span>
</div>
)}
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandant-spezifisch' : 'Global'}</strong>
{' '}(kann nicht geändert werden)
</span>
</div>
<FormGeneratorForm
attributes={editFields}
data={{
...editingRole,
scope: editingRole.mandateId ? 'mandate' : 'global'
}}
data={editingRole}
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText="Speichern"
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
</>

View file

@ -1,273 +0,0 @@
/**
* AdminRolesPage
*
* Admin page for managing global RBAC Roles using FormGeneratorTable.
*/
import React, { useState, useMemo } from 'react';
import { useAdminRoles, type Role } from '../../hooks/useRoles';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUserShield } from 'react-icons/fa';
import styles from './Admin.module.css';
export const AdminRolesPage: React.FC = () => {
const {
roles,
attributes,
columns,
permissions,
pagination,
loading,
error,
refetch,
fetchRoleById,
handleCreate,
handleUpdate,
handleDelete,
handleInlineUpdate,
updateOptimistically,
} = useAdminRoles();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Columns for global roles - exclude mandate/feature-specific columns
// Global roles don't have mandate or feature instance associations
const displayColumns = useMemo(() => {
const excludedColumns = ['mandateId', 'featureInstanceId', 'featureCode'];
return columns.filter(col => !excludedColumns.includes(col.key));
}, [columns]);
// Check permissions
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click
const handleEditClick = async (role: Role) => {
const fullRole = await fetchRoleById(role.id);
if (fullRole) {
setEditingRole(fullRole);
}
};
// Handle create submit
const handleCreateSubmit = async (data: Partial<Role>) => {
setIsSubmitting(true);
try {
const success = await handleCreate(data);
if (success) {
setShowCreateModal(false);
}
} finally {
setIsSubmitting(false);
}
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<Role>) => {
if (!editingRole) return;
setIsSubmitting(true);
try {
const success = await handleUpdate(editingRole.id, data);
if (success) {
setEditingRole(null);
}
} finally {
setIsSubmitting(false);
}
};
// Handle delete
const handleDeleteRole = async (role: Role) => {
if (window.confirm(`Möchten Sie die Rolle "${role.roleLabel || role.id}" wirklich löschen?`)) {
await handleDelete(role.id);
}
};
// Form attributes from backend - filter for create/edit forms
// Exclude fields not relevant for global roles (mandateId, featureInstanceId, etc.)
const formAttributes: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole'];
return attributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({
...attr,
// Map backend type names to form types
type: attr.type === 'multilingual' ? 'multilingual' : attr.type,
})) as AttributeDefinition[];
}, [attributes]);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Rollen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Globale Rollen</h1>
<p className={styles.pageSubtitle}>Verwalten Sie die systemweiten RBAC-Rollen</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Rolle
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && roles.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Rollen...</span>
</div>
) : roles.length === 0 ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Rollen vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Rolle, um Berechtigungen zu definieren.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Rolle erstellen
</button>
)}
</div>
) : (
<FormGeneratorTable
data={roles}
columns={displayColumns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
}] : []),
]}
onDelete={handleDeleteRole}
hookData={{
refetch,
permissions,
pagination,
handleDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Rollen gefunden"
/>
)}
</div>
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Rolle</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? 'Erstelle...' : 'Erstellen'}
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingRole && (
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Rolle bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingRole(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingRole}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminRolesPage;

View file

@ -5,7 +5,7 @@
* Allows assigning users to mandates and managing their roles within mandates.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } 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';
@ -27,6 +27,9 @@ export const AdminUserMandatesPage: React.FC = () => {
fetchRoles,
fetchAllUsers,
} = useUserMandates();
// Store current mandateId for refetch
const currentMandateIdRef = useRef<string>('');
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
@ -59,11 +62,23 @@ export const AdminUserMandatesPage: React.FC = () => {
// Load users when mandate changes
useEffect(() => {
if (selectedMandateId) {
currentMandateIdRef.current = selectedMandateId;
fetchMandateUsers(selectedMandateId);
fetchRoles(selectedMandateId).then(setRoles);
}
}, [selectedMandateId, fetchMandateUsers, fetchRoles]);
// Refetch wrapper that accepts pagination params from FormGeneratorTable
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
const mandateId = currentMandateIdRef.current;
if (!mandateId) return;
// If pagination params provided, pass them; otherwise just use mandateId
if (paginationParams && Object.keys(paginationParams).length > 0) {
return fetchMandateUsers(paginationParams);
}
return fetchMandateUsers(mandateId);
}, [fetchMandateUsers]);
// Load all users for the add modal
useEffect(() => {
fetchAllUsers().then(setAllUsers);
@ -357,8 +372,8 @@ export const AdminUserMandatesPage: React.FC = () => {
]}
onDelete={handleRemoveUser}
hookData={{
refetch: fetchMandateUsers,
pagination,
refetch: refetchWithParams,
pagination: pagination,
handleDelete: async (userMandateId: string) => {
// Find user by UserMandate ID to get userId for API call
const user = users.find(u => u.id === userMandateId);
@ -401,7 +416,7 @@ export const AdminUserMandatesPage: React.FC = () => {
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText="Hinzufügen"
submitButtonText={isSubmitting ? 'Hinzufügen...' : 'Hinzufügen'}
cancelButtonText="Abbrechen"
/>
)}
@ -430,7 +445,7 @@ export const AdminUserMandatesPage: React.FC = () => {
mode="edit"
onSubmit={handleEditRoles}
onCancel={() => setEditingUser(null)}
submitButtonText="Speichern"
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
</div>

View file

@ -6,9 +6,9 @@
export { AdminMandatesPage } from './AdminMandatesPage';
export { AdminUsersPage } from './AdminUsersPage';
export { AdminRolesPage } from './AdminRolesPage';
export { AdminUserMandatesPage } from './AdminUserMandatesPage';
export { AdminFeatureAccessPage } from './AdminFeatureAccessPage';
export { AdminInvitationsPage } from './AdminInvitationsPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';