serverside filter and sort for form generic
This commit is contained in:
parent
537b624c59
commit
d3873223f5
10 changed files with 625 additions and 414 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
442
src/pages/admin/AdminFeatureRolesPage.tsx
Normal file
442
src/pages/admin/AdminFeatureRolesPage.tsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
Loading…
Reference in a new issue