diff --git a/src/App.tsx b/src/App.tsx index 7a8639b..56c762e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR'; import StorePage from './pages/Store'; import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; import { FeatureViewPage } from './pages/FeatureView'; -import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin'; +import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminUserRoleTemplatesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; @@ -212,7 +212,8 @@ function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/src/components/admin/MandateRolesPermissionsPanel.module.css b/src/components/admin/MandateRolesPermissionsPanel.module.css index 53fe9e6..8af4b44 100644 --- a/src/components/admin/MandateRolesPermissionsPanel.module.css +++ b/src/components/admin/MandateRolesPermissionsPanel.module.css @@ -175,6 +175,35 @@ white-space: nowrap; } +.createTemplateField { + margin-bottom: 1.25rem; +} + +.createTemplateLabel { + display: block; + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary, #1a1a1a); +} + +.createTemplateSelect { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-secondary, #fff); + color: var(--text-primary, #1a1a1a); +} + +.createTemplateHint { + margin-top: 0.5rem; + font-size: 0.8125rem; + color: var(--text-secondary, #666); + line-height: 1.4; +} + .panelInfoBoxIcon { flex-shrink: 0; margin-top: 0.15em; diff --git a/src/components/admin/MandateRolesPermissionsPanel.tsx b/src/components/admin/MandateRolesPermissionsPanel.tsx index 21dd3ab..06aa615 100644 --- a/src/components/admin/MandateRolesPermissionsPanel.tsx +++ b/src/components/admin/MandateRolesPermissionsPanel.tsx @@ -9,7 +9,6 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { useMandateRoles, type Role, - type RoleCreate, type RoleUpdate, } from '../../hooks/useMandateRoles'; import { useApiRequest } from '../../hooks/useApi'; @@ -74,7 +73,8 @@ export const MandateRolesPermissionsPanel: React.FC([]); const [deletingRoleId, setDeletingRoleId] = useState(null); + const [userRoleTemplates, setUserRoleTemplates] = useState([]); + const [templatesLoading, setTemplatesLoading] = useState(false); + const [selectedTemplateId, setSelectedTemplateId] = useState(''); + const [createFormData, setCreateFormData] = useState>({}); const shouldShowInfoBox = showInfoBox ?? showRoleActions; const shouldShowCreateRole = showCreateRole ?? showRoleActions; @@ -107,6 +111,42 @@ export const MandateRolesPermissionsPanel: React.FC { + if (!showCreateModal || !shouldShowCreateRole) return; + setTemplatesLoading(true); + // fetchTemplatesOnly makes a direct API call without touching the shared + // `roles` state, so the displayed mandate roles are never clobbered. + fetchTemplatesOnly() + .then(setUserRoleTemplates) + .catch(() => setUserRoleTemplates([])) + .finally(() => setTemplatesLoading(false)); + }, [showCreateModal, shouldShowCreateRole, fetchTemplatesOnly]); + + const existingMandateRoleLabels = useMemo( + () => new Set(roles.map(r => r.roleLabel)), + [roles], + ); + + const closeCreateModal = () => { + if (isSubmitting) return; + setShowCreateModal(false); + setSelectedTemplateId(''); + setCreateFormData({}); + }; + + const handleTemplateSelect = (templateId: string) => { + setSelectedTemplateId(templateId); + const template = userRoleTemplates.find(t => t.id === templateId); + if (template) { + setCreateFormData({ + roleLabel: template.roleLabel, + description: template.description ?? {}, + }); + } else { + setCreateFormData({}); + } + }; + const scopeTypeLabel = useCallback( (scopeType?: Role['scopeType']) => { switch (scopeType) { @@ -125,25 +165,10 @@ export const MandateRolesPermissionsPanel: React.FC { const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType']; - const fields = backendAttributes + return backendAttributes .filter(attr => !excludedFields.includes(attr.name)) .map(attr => ({ ...attr })) as AttributeDefinition[]; - - if (fields.length > 0) { - fields.push({ - name: 'scope', - label: t('Geltungsbereich'), - type: 'enum' as AttributeDefinition['type'], - required: true, - default: scopeFilter === 'global' ? 'global' : 'mandate', - options: [ - { value: 'mandate', label: t('Nur dieser Mandant') }, - { value: 'global', label: t('Template bei neuen Mandanten') }, - ], - }); - } - return fields; - }, [backendAttributes, scopeFilter, t]); + }, [backendAttributes]); const editFields: AttributeDefinition[] = useMemo(() => { const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType']; @@ -155,28 +180,29 @@ export const MandateRolesPermissionsPanel: React.FC; - scope: 'mandate' | 'global'; }) => { if (!mandateId) return; + if (!selectedTemplateId) { + showError(t('Fehler'), t('Bitte eine Rollen-Vorlage wählen')); + return; + } setIsSubmitting(true); try { - const roleData: RoleCreate = { + const result = await createRoleFromTemplate(selectedTemplateId, mandateId, { roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'), description: data.description, - mandateId: data.scope === 'mandate' ? mandateId : undefined, - }; - const result = await createRole(roleData, mandateId); + }); if (result.success) { - setShowCreateModal(false); + closeCreateModal(); await loadRoles(); } else { - showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle')); + showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle aus Vorlage')); } } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('Fehler beim Erstellen der Rolle'); + const message = err instanceof Error ? err.message : t('Fehler beim Erstellen der Rolle aus Vorlage'); showError(t('Fehler'), message); } finally { setIsSubmitting(false); @@ -439,25 +465,63 @@ export const MandateRolesPermissionsPanel: React.FC !isSubmitting && setShowCreateModal(false)} + title={t('Rolle aus Vorlage erstellen')} + onClose={closeCreateModal} size="medium" closable={!isSubmitting} > - {createFields.length === 0 ? ( + {createFields.length === 0 || templatesLoading ? (
{t('Lade Formular')}
) : ( - setShowCreateModal(false)} - submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle erstellen')} - cancelButtonText={t('Abbrechen')} - /> + <> +
+ + +

+ {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.')} - -
- )} - - {/* Content */} - {!selectedMandateId ? ( -
- -

{t('Kein Mandant ausgewählt')}

-

- {t('Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.')} -

-
- ) : ( -
- row.isSystemRole ? { disabled: true, message: t('System-Rollen können nicht bearbeitet werden') } : false - }, - { - type: 'delete' as const, - title: t('Rolle löschen'), - disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: t('System-Rollen können nicht gelöscht werden') } : false - } - ]} - onDelete={handleDeleteRole} - hookData={{ - refetch: refetchWithParams, - pagination: pagination, - handleDelete: handleDeleteRole, - }} - emptyMessage={t('Keine Rollen gefunden')} - /> -
- )} - - {/* Create Role Modal */} - {showCreateModal && ( -
-
-
-

{t('Neue Rolle erstellen')}

- -
-
- {createFields.length === 0 ? ( -
-
- {t('Lade Formular')} -
- ) : ( - setShowCreateModal(false)} - submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle erstellen')} - cancelButtonText={t('Abbrechen')} - /> - )} -
-
-
- )} - - {/* Edit Role Modal */} - {editingRole && ( -
-
-
-

- {t('Rolle bearbeiten')}: {editingRole.roleLabel} -

- -
-
- {editFields.length === 0 ? ( -
-
- {t('Lade Formular')} -
- ) : ( - <> -
- - - {t('Geltungsbereich')}:{' '} - - {editingRole.mandateId ? t('Mandanten-Instanz') : t('Template (global)')} - {' '} - ({t('kann nicht geändert werden')}) - -
- setEditingRole(null)} - submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')} - cancelButtonText={t('Abbrechen')} - /> - - )} -
-
-
- )} -
- ); -}; - -export default AdminMandateRolesPage; diff --git a/src/pages/admin/AdminUserRoleTemplatesPage.tsx b/src/pages/admin/AdminUserRoleTemplatesPage.tsx new file mode 100644 index 0000000..d008fe0 --- /dev/null +++ b/src/pages/admin/AdminUserRoleTemplatesPage.tsx @@ -0,0 +1,366 @@ +/** + * AdminUserRoleTemplatesPage + * + * Manage user role templates (system + global templates with mandateId=null). + * Mandate-specific roles are managed per tenant on Admin Mandates (roles popup). + * Feature role templates: AdminFeatureRolesPage. + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { + useMandateRoles, + type Role, + type RoleCreate, + type RoleUpdate, + type PaginationParams, +} from '../../hooks/useMandateRoles'; +import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; +import { FaPlus, FaSync, FaUserShield } 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'; + +export const AdminUserRoleTemplatesPage: React.FC = () => { + const { t, currentLanguage } = useLanguage(); + const { request } = useApiRequest(); + const { showError, showWarning } = useToast(); + const { + roles, + loading, + error, + pagination, + fetchUserRoleTemplates, + createRole, + updateRole, + deleteRole, + } = useMandateRoles(); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'RoleView') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [request]); + + useEffect(() => { + fetchUserRoleTemplates(); + }, [fetchUserRoleTemplates]); + + const refetchWithParams = useCallback( + async (paginationParams?: PaginationParams) => { + return fetchUserRoleTemplates(paginationParams); + }, + [fetchUserRoleTemplates], + ); + + const getDescriptionText = (desc: unknown) => { + if (!desc) return '-'; + if (typeof desc === 'string') return desc; + if (typeof desc === 'object') { + const record = desc as Record; + return ( + record[currentLanguage] || + record['xx'] || + Object.values(record).find(v => 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 }, + ], + [currentLanguage], + ); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + + const createFields: AttributeDefinition[] = useMemo(() => { + const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType']; + return backendAttributes + .filter(attr => !excludedFields.includes(attr.name)) + .map(attr => ({ ...attr })) as AttributeDefinition[]; + }, [backendAttributes]); + + const editFields: AttributeDefinition[] = useMemo(() => { + const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType']; + return backendAttributes + .filter(attr => !excludedFields.includes(attr.name)) + .map(attr => ({ + ...attr, + readonly: attr.name === 'roleLabel' ? true : attr.readonly, + })) as AttributeDefinition[]; + }, [backendAttributes]); + + const handleCreateTemplate = async (data: { roleLabel: string; description?: Record }) => { + setIsSubmitting(true); + try { + const roleData: RoleCreate = { + roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'), + description: data.description, + mandateId: undefined, + }; + + const result = await createRole(roleData); + if (result.success) { + setShowCreateModal(false); + await fetchUserRoleTemplates(); + } else { + showError(t('Fehler'), result.error || t('Fehler beim Erstellen des Rollen-Templates')); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('Fehler beim Erstellen des Rollen-Templates'); + showError(t('Fehler'), message); + } finally { + setIsSubmitting(false); + } + }; + + const handleEditTemplate = async (data: RoleUpdate) => { + if (!editingRole) return; + setIsSubmitting(true); + try { + const updateData: RoleUpdate = { + roleLabel: data.roleLabel, + description: data.description, + }; + + const result = await updateRole(editingRole.id, updateData); + if (result.success) { + setEditingRole(null); + await fetchUserRoleTemplates(); + } else { + showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren des Rollen-Templates')); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('Fehler beim Aktualisieren des Rollen-Templates'); + showError(t('Fehler'), message); + } finally { + setIsSubmitting(false); + } + }; + + const handleDeleteTemplate = async (roleId: string): Promise => { + const role = roles.find(r => r.id === roleId); + if (role?.isSystemRole) { + showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.')); + return false; + } + + const result = await deleteRole(roleId); + if (result.success) { + await fetchUserRoleTemplates(); + return true; + } else { + showError(t('Fehler'), result.error || t('Fehler beim Löschen des Rollen-Templates')); + return false; + } + }; + + const handleEditClick = (role: Role) => { + setEditingRole(role); + }; + + if (error) { + return ( +
+
+ ⚠️ +

+ {t('Fehler')}: {error} +

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

{t('User Role Templates')}

+

+ {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.')} + +
+ +
+ + row.isSystemRole + ? { disabled: true, message: t('System-Rollen können nicht bearbeitet werden') } + : false, + }, + { + type: 'delete' as const, + title: t('Rollen-Template löschen'), + disabled: (row: Role) => + row.isSystemRole + ? { disabled: true, message: t('System-Rollen können nicht gelöscht werden') } + : false, + }, + ]} + onDelete={handleDeleteTemplate} + hookData={{ + refetch: refetchWithParams, + pagination: pagination, + handleDelete: handleDeleteTemplate, + }} + emptyMessage={t('Keine Rollen-Templates gefunden')} + /> +
+ + {showCreateModal && ( +
+
+
+

{t('Neues Rollen-Template erstellen')}

+ +
+
+ {createFields.length === 0 ? ( +
+
+ {t('Lade Formular')} +
+ ) : ( + setShowCreateModal(false)} + submitButtonText={isSubmitting ? t('Erstelle…') : t('Rollen-Template erstellen')} + cancelButtonText={t('Abbrechen')} + /> + )} +
+
+
+ )} + + {editingRole && ( +
+
+
+

+ {t('Rollen-Template bearbeiten')}: {editingRole.roleLabel} +

+ +
+
+ {editFields.length === 0 ? ( +
+
+ {t('Lade Formular')} +
+ ) : ( + <> +
+ + + {t('Geltungsbereich')}:{' '} + + {editingRole.isSystemRole ? t('System-Template') : t('Template (global)')} + {' '} + ({t('kann nicht geändert werden')}) + +
+ setEditingRole(null)} + submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')} + cancelButtonText={t('Abbrechen')} + /> + + )} +
+
+
+ )} +
+ ); +}; + +export default AdminUserRoleTemplatesPage; diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index 8e7c1c1..ae49cf7 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -10,7 +10,7 @@ export { AdminUsersPage } from './AdminUsersPage'; export { AdminUserMandatesPage } from './AdminUserMandatesPage'; export { AdminFeatureAccessPage } from './AdminFeatureAccessPage'; export { AdminInvitationsPage } from './AdminInvitationsPage'; -export { AdminMandateRolesPage } from './AdminMandateRolesPage'; +export { AdminUserRoleTemplatesPage } from './AdminUserRoleTemplatesPage'; export { AdminFeatureRolesPage } from './AdminFeatureRolesPage'; export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage'; export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';