diff --git a/src/App.tsx b/src/App.tsx index 5bac296..a88720e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> - } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 7fc4137..04b233f 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -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({ »» - {/* Total items count */} + {/* Total items count - always show actual displayed data length */} - ({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')}) )} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 61a0e72..90ea381 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -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); diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 4c84f42..179c927 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -22,7 +22,7 @@ import { useMandates, useFeatureStore } from '../../stores/featureStore'; import { useCurrentUser } from '../../hooks/useUsers'; import { FEATURE_REGISTRY, getLabel } from '../../types/mandate'; import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate'; -import { FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserShield, FaUserTag, FaCubes, FaEnvelopeOpenText, FaKey, 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: , path: '/admin/invitations', }, - { - id: 'admin-roles', - label: 'Globale Rollen', - icon: , - 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: , path: '/admin/mandate-roles', }, @@ -231,6 +225,12 @@ export const MandateNavigation: React.FC = () => { icon: , path: '/admin/user-mandates', }, + { + id: 'admin-feature-roles', + label: 'Feature-Rollen', + icon: , + path: '/admin/feature-roles', + }, { id: 'admin-feature-instances', label: 'Feature-Instanzen', diff --git a/src/hooks/useMandateRoles.ts b/src/hooks/useMandateRoles.ts index 3f59a73..bf6ccbc 100644 --- a/src/hooks/useMandateRoles.ts +++ b/src/hooks/useMandateRoles.ts @@ -42,6 +42,7 @@ export interface PaginationParams { search?: string; filters?: Record; 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(null); // Store current mandateId for refetch - const currentMandateIdRef = useRef(); + const currentMandateIdRef = useRef(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 => { setLoading(true); setError(null); @@ -77,27 +80,43 @@ export function useMandateRoles() { const headers: Record = {}; 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 = {}; - 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'; diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx new file mode 100644 index 0000000..3bfa17f --- /dev/null +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -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([]); + const [selectedFeatureCode, setSelectedFeatureCode] = useState(''); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingRole, setEditingRole] = useState(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) => ( + + {value} + + ) + }, + ], []); + + // 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 ( +
+
+ ⚠️ +

{error}

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

Feature-Rollen

+

Template-Rollen für Feature-Instanzen verwalten

+
+
+ + {/* Feature Selector */} +
+
+ + +
+ + {selectedFeatureCode && ( +
+ + +
+ )} +
+ + {/* Info Box */} + {selectedFeatureCode && ( +
+ + + Feature-Template-Rollen werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert. + Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus. + +
+ )} + + {/* Content */} + {!selectedFeatureCode ? ( +
+ +

Kein Feature ausgewählt

+

+ Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten. +

+
+ ) : loading && roles.length === 0 ? ( +
+
+ Lade Feature-Rollen... +
+ ) : roles.length === 0 ? ( +
+ +

Keine Rollen

+

+ Es gibt noch keine Template-Rollen für dieses Feature. +

+ +
+ ) : ( +
+ +
+ )} + + {/* Create Role Modal */} + {showCreateModal && ( +
setShowCreateModal(false)}> +
e.stopPropagation()}> +
+

Neue Feature-Rolle erstellen

+ +
+
+
+ + Feature: {selectedFeatureCode} +
+ setShowCreateModal(false)} + submitButtonText={isSubmitting ? 'Erstelle...' : 'Rolle erstellen'} + cancelButtonText="Abbrechen" + /> +
+
+
+ )} + + {/* Edit Role Modal */} + {editingRole && ( +
setEditingRole(null)}> +
e.stopPropagation()}> +
+

Feature-Rolle bearbeiten

+ +
+
+
+ + Feature: {editingRole.featureCode} +
+ setEditingRole(null)} + submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'} + cancelButtonText="Abbrechen" + /> +
+
+
+ )} +
+ ); +}; + +export default AdminFeatureRolesPage; diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index d6f167e..8496961 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -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(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([]); + + // 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 ? ( - - {value} - - ) : '-' - }, ], []); // 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 = () => {
-

Mandanten-Rollen

-

Verwalten Sie Rollen innerhalb eines Mandanten

+

Rollen

+

Verwalten Sie System-, globale und mandantenspezifische Rollen

@@ -344,8 +337,8 @@ export const AdminMandateRolesPage: React.FC = () => {