+ {userRoleTemplates.length === 0
+ ? t('Keine Vorlagen verfügbar.')
+ : t(
+ 'Berechtigungen (AccessRules) der Vorlage werden in die neue Mandanten-Rolle übernommen. Bezeichnung und Beschreibung können angepasst werden.',
+ )}
+
+
+ }}
+ mode="create"
+ onSubmit={handleCreateRoleFromTemplate}
+ onCancel={closeCreateModal}
+ submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle aus Vorlage erstellen')}
+ cancelButtonText={t('Abbrechen')}
+ />
+ >
)}
)}
diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx
index a53ba09..5b57498 100644
--- a/src/config/pageRegistry.tsx
+++ b/src/config/pageRegistry.tsx
@@ -17,7 +17,7 @@
import React from 'react';
import {
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
- FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
+ FaCubes, FaEnvelopeOpenText, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
@@ -64,7 +64,7 @@ export const PAGE_ICONS: Record = {
'page.admin.users': ,
'page.admin.invitations': ,
'page.admin.mandates': ,
- 'page.admin.roles': ,
+ 'page.admin.userRoleTemplates': ,
'page.admin.role-permissions': ,
'page.admin.user-mandates': ,
'page.admin.userMandates': ,
diff --git a/src/hooks/useMandateRoles.ts b/src/hooks/useMandateRoles.ts
index d1294b7..ff7896c 100644
--- a/src/hooks/useMandateRoles.ts
+++ b/src/hooks/useMandateRoles.ts
@@ -278,12 +278,83 @@ export function useMandateRoles() {
return roles.filter(r => r.isTemplate === true);
}, [roles]);
+ /** Global + system user role templates (no mandateId).
+ * Used by AdminUserRoleTemplatesPage — updates the shared `roles` state.
+ * NOTE: passes `undefined` as first arg so fetchRoles skips both branches and
+ * does NOT inherit currentMandateIdRef, ensuring no mandate header is sent. */
+ const fetchUserRoleTemplates = useCallback(
+ async (paginationParams?: PaginationParams): Promise => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return fetchRoles(undefined as any, { ...paginationParams });
+ },
+ [fetchRoles],
+ );
+
+ /** Fetch global/system templates without touching the shared `roles` state.
+ * Safe to call from components that already manage their own role list
+ * (e.g. MandateRolesPermissionsPanel) so the displayed roles aren't clobbered. */
+ const fetchTemplatesOnly = useCallback(async (): Promise => {
+ try {
+ const response = await api.get('/api/rbac/roles', {
+ params: { includeTemplates: 'false', scopeFilter: 'global' },
+ });
+ if (response.data?.items && Array.isArray(response.data.items)) {
+ return response.data.items as Role[];
+ }
+ if (Array.isArray(response.data)) {
+ return response.data as Role[];
+ }
+ return [];
+ } catch {
+ return [];
+ }
+ }, []);
+
+ /**
+ * Create a mandate role from a user role template (copies AccessRules).
+ */
+ const createRoleFromTemplate = useCallback(
+ async (
+ templateRoleId: string,
+ targetMandateId: string,
+ data?: { roleLabel?: string; description?: Record },
+ ): Promise<{ success: boolean; data?: Role; error?: string }> => {
+ setLoading(true);
+ setError(null);
+ try {
+ const headers: Record = { 'X-Mandate-Id': targetMandateId };
+ const response = await api.post(
+ '/api/rbac/roles/from-template',
+ {
+ templateRoleId,
+ mandateId: targetMandateId,
+ roleLabel: data?.roleLabel,
+ description: data?.description,
+ },
+ { headers },
+ );
+ return { success: true, data: response.data };
+ } catch (err: any) {
+ const errorMessage =
+ err.response?.data?.detail || err.message || 'Failed to create role from template';
+ setError(errorMessage);
+ return { success: false, error: errorMessage };
+ } finally {
+ setLoading(false);
+ }
+ },
+ [],
+ );
+
return {
roles,
loading,
error,
pagination,
fetchRoles,
+ fetchUserRoleTemplates,
+ fetchTemplatesOnly,
+ createRoleFromTemplate,
getRole,
createRole,
updateRole,
diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx
deleted file mode 100644
index b079165..0000000
--- a/src/pages/admin/AdminMandateRolesPage.tsx
+++ /dev/null
@@ -1,479 +0,0 @@
-/**
- * AdminMandateRolesPage
- *
- * 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, useRef } from 'react';
-import { useNavigate } from 'react-router-dom';
-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, FaCube } from 'react-icons/fa';
-import { useToast } from '../../contexts/ToastContext';
-import { useApiRequest } from '../../hooks/useApi';
-import { fetchAttributes } from '../../api/attributesApi';
-import { resolveColumnTypes } from '../../utils/columnTypeResolver';
-import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
-import styles from './Admin.module.css';
-
-import { useLanguage } from '../../providers/language/LanguageContext';
-import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
-
-export const AdminMandateRolesPage: React.FC = () => {
- const { t, currentLanguage } = useLanguage();
-
- const navigate = useNavigate();
- const { request } = useApiRequest();
- const { showError, showWarning } = useToast();
- const {
- roles,
- loading,
- error,
- pagination,
- fetchRoles,
- createRole,
- updateRole,
- deleteRole,
- } = useMandateRoles();
-
- const { fetchMandates } = useUserMandates();
-
- // State
- const [mandates, setMandates] = useState([]);
- const [selectedMandateId, setSelectedMandateId] = useState('');
- const [showCreateModal, setShowCreateModal] = useState(false);
- const [editingRole, setEditingRole] = useState(null);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
- const [backendAttributes, setBackendAttributes] = useState([]);
-
- // Store current filter state for refetch
- const currentScopeFilterRef = useRef(scopeFilter);
- currentScopeFilterRef.current = scopeFilter;
-
- // Load mandates and attributes on mount
- useEffect(() => {
- const loadMandates = async () => {
- const data = await fetchMandates();
- setMandates(data);
- if (data.length > 0 && !selectedMandateId) {
- setSelectedMandateId(data[0].id);
- }
- };
- loadMandates();
- fetchAttributes(request, 'RoleView')
- .then(setBackendAttributes)
- .catch(() => setBackendAttributes([]));
- }, [fetchMandates, request]);
-
- // Load roles when mandate or scopeFilter changes
- useEffect(() => {
- if (selectedMandateId) {
- fetchRoles(selectedMandateId, { scopeFilter });
- }
- }, [selectedMandateId, scopeFilter, fetchRoles]);
-
- // Refetch wrapper that accepts pagination params from FormGeneratorTable
- // and includes the current mandateId and scopeFilter
- const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
- if (!selectedMandateId) return;
- // Merge pagination params with current filter state
- return fetchRoles(selectedMandateId, {
- ...paginationParams,
- scopeFilter: currentScopeFilterRef.current
- });
- }, [selectedMandateId, fetchRoles]);
-
- const getDescriptionText = (desc: any) => {
- if (!desc) return '-';
- if (typeof desc === 'string') return desc;
- if (typeof desc === 'object') {
- return desc[currentLanguage] || desc['xx'] || Object.values(desc).find((v: any) => typeof v === 'string' && v.trim()) || '-';
- }
- return String(desc);
- };
-
- const _rawColumns: ColumnConfig[] = useMemo(() => [
- { key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
- {
- key: 'description',
- sortable: false,
- filterable: false,
- width: 250,
- formatter: (value: string) => getDescriptionText(value),
- },
- { key: 'scopeType', sortable: true, filterable: true, width: 160 },
- { key: 'userCount', sortable: true, filterable: true, width: 100 },
- ], []);
-
- const columns = useMemo(
- () => resolveColumnTypes(_rawColumns, backendAttributes),
- [_rawColumns, backendAttributes],
- );
-
- // Form attributes from backend - for create form
- const createFields: AttributeDefinition[] = useMemo(() => {
- const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
-
- const fields = backendAttributes
- .filter(attr => !excludedFields.includes(attr.name))
- .map(attr => ({ ...attr })) as AttributeDefinition[];
-
- // Add scope field for mandate/global selection (not a model attribute)
- if (fields.length > 0) {
- fields.push({
- name: 'scope',
- label: t('Geltungsbereich'),
- type: 'enum' as any,
- required: true,
- default: 'mandate',
- options: [
- { value: 'mandate', label: t('Nur dieser Mandant') },
- { value: 'global', label: t('Template bei neuen Mandanten') },
- ]
- });
- }
- return fields;
- }, [backendAttributes, t]);
-
- // 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', 'scopeType'];
-
- const fields = backendAttributes
- .filter(attr => !excludedFields.includes(attr.name))
- .map(attr => ({
- ...attr,
- // Mark roleLabel as readonly (cannot change after creation)
- readonly: attr.name === 'roleLabel' ? true : attr.readonly,
- })) as AttributeDefinition[];
-
- // No scope field for edit - context is immutable!
- return fields;
- }, [backendAttributes]);
-
- // Handle create role
- const handleCreateRole = async (data: { roleLabel: string; description?: Record; scope: 'mandate' | 'global' }) => {
- if (!selectedMandateId) return;
- setIsSubmitting(true);
- try {
- const roleData: RoleCreate = {
- roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
- description: data.description,
- mandateId: data.scope === 'mandate' ? selectedMandateId : undefined
- };
-
- const result = await createRole(roleData, selectedMandateId);
-
- if (result.success) {
- setShowCreateModal(false);
- await fetchRoles(selectedMandateId, { scopeFilter });
- } else {
- showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle'));
- }
- } catch (err: any) {
- console.error('Create role error:', err);
- showError(t('Fehler'), err.message || t('Fehler beim Erstellen der Rolle'));
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // Handle edit role
- const handleEditRole = async (data: RoleUpdate & { scope?: 'mandate' | 'global' }) => {
- if (!editingRole) return;
- setIsSubmitting(true);
- try {
- // Convert scope to mandateId - NOTE: Context fields are IMMUTABLE per concept!
- // We should not be changing mandateId after creation
- const updateData: RoleUpdate = {
- roleLabel: data.roleLabel,
- description: data.description,
- // mandateId is immutable - don't include in update
- };
-
- const result = await updateRole(editingRole.id, updateData);
-
- if (result.success) {
- setEditingRole(null);
- if (selectedMandateId) {
- await fetchRoles(selectedMandateId, { scopeFilter });
- }
- } else {
- showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rolle'));
- }
- } catch (err: any) {
- console.error('Update role error:', err);
- showError(t('Fehler'), err.message || t('Fehler beim Aktualisieren der Rolle'));
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // Handle delete role (confirmation handled by DeleteActionButton)
- const handleDeleteRole = async (role: Role) => {
- if (role.isSystemRole) {
- showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.'));
- return;
- }
-
- const result = await deleteRole(role.id);
- if (result.success) {
- // Refetch to update the list
- await fetchRoles(selectedMandateId, { scopeFilter });
- } else {
- showError(t('Fehler'), result.error || t('Fehler beim Löschen der Rolle'));
- }
- };
-
- // Handle edit click
- const handleEditClick = (role: Role) => {
- setEditingRole(role);
- };
-
- if (error && !selectedMandateId) {
- return (
-
-
- ⚠️
-
- {t('Fehler')}: {error}
-
-
-
-
- );
- }
-
- return (
-
-
-
-
{t('Rollen')}
-
{t('Verwalten Sie systemweite und globale')}
-
-
-
-
-
-
- {/* Mandate Selector and Filters */}
-
-
-
-
-
-
-
-
-
-
-
- {selectedMandateId && (
-
-
-
-
- )}
-
-
- {/* Info Box */}
- {selectedMandateId && (
-
-
-
- {t('System-Templates')}{' '}
- {t('(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. Templates selbst können nicht gelöscht werden.')}{' '}
- {t('Mandanten-Rollen')}{' '}
- {t('gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.')}
-
-
+ {t('Verwalten Sie System- und globale Rollen-Vorlagen. Diese werden bei neuen Mandanten kopiert.')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('System-Templates')}{' '}
+ {t(
+ '(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert und können hier nicht gelöscht werden.',
+ )}{' '}
+ {t('Globale Templates')}{' '}
+ {t('gelten für alle neuen Mandanten. Mandanten-spezifische Rollen verwalten Sie unter Mandanten.')}
+
+