updated tenant role page to be user role templates page

This commit is contained in:
Ida 2026-06-04 13:55:14 +02:00
parent dfa36c17ef
commit 849efa6ed7
8 changed files with 575 additions and 523 deletions

View file

@ -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() {
<Route path="feature-roles" element={<AdminFeatureRolesPage />} />
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="user-role-templates" element={<AdminUserRoleTemplatesPage />} />
<Route path="mandate-roles" element={<Navigate to="/admin/user-role-templates" replace />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing">
<Route index element={<Navigate to="/billing/admin" replace />} />

View file

@ -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;

View file

@ -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<MandateRolesPermissionsPanel
loading,
error,
fetchRoles,
createRole,
fetchTemplatesOnly,
createRoleFromTemplate,
updateRole,
deleteRole,
} = useMandateRoles();
@ -85,6 +85,10 @@ export const MandateRolesPermissionsPanel: React.FC<MandateRolesPermissionsPanel
const [isSubmitting, setIsSubmitting] = useState(false);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
const [deletingRoleId, setDeletingRoleId] = useState<string | null>(null);
const [userRoleTemplates, setUserRoleTemplates] = useState<Role[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [createFormData, setCreateFormData] = useState<Record<string, unknown>>({});
const shouldShowInfoBox = showInfoBox ?? showRoleActions;
const shouldShowCreateRole = showCreateRole ?? showRoleActions;
@ -107,6 +111,42 @@ export const MandateRolesPermissionsPanel: React.FC<MandateRolesPermissionsPanel
}
}, [request, needsRoleFormAttributes]);
useEffect(() => {
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<MandateRolesPermissionsPanel
const createFields: AttributeDefinition[] = useMemo(() => {
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<MandateRolesPermissionsPanel
})) as AttributeDefinition[];
}, [backendAttributes]);
const handleCreateRole = async (data: {
const handleCreateRoleFromTemplate = async (data: {
roleLabel: string;
description?: Record<string, string>;
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<MandateRolesPermissionsPanel
{showCreateModal && (
<Popup
isOpen={true}
title={t('Neue Rolle erstellen')}
onClose={() => !isSubmitting && setShowCreateModal(false)}
title={t('Rolle aus Vorlage erstellen')}
onClose={closeCreateModal}
size="medium"
closable={!isSubmitting}
>
{createFields.length === 0 ? (
{createFields.length === 0 || templatesLoading ? (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle erstellen')}
cancelButtonText={t('Abbrechen')}
/>
<>
<div className={panelStyles.createTemplateField}>
<label className={panelStyles.createTemplateLabel} htmlFor="role-template-select">
{t('Rollen-Vorlage')} *
</label>
<select
id="role-template-select"
className={panelStyles.createTemplateSelect}
value={selectedTemplateId}
onChange={e => handleTemplateSelect(e.target.value)}
disabled={isSubmitting}
>
<option value="">{t('Vorlage wählen')}</option>
{userRoleTemplates.map(template => {
const alreadyInMandate = existingMandateRoleLabels.has(template.roleLabel);
return (
<option key={template.id} value={template.id}>
{template.roleLabel}
{template.isSystemRole ? ` (${t('System-Template')})` : ''}
{template.scopeType === 'global' && !template.isSystemRole
? ` (${t('Template')})`
: ''}
{alreadyInMandate ? `${t('bereits vorhanden')}` : ''}
</option>
);
})}
</select>
<p className={panelStyles.createTemplateHint}>
{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.',
)}
</p>
</div>
<FormGeneratorForm
key={selectedTemplateId || 'no-template'}
attributes={createFields}
data={createFormData as { roleLabel: string; description?: Record<string, string> }}
mode="create"
onSubmit={handleCreateRoleFromTemplate}
onCancel={closeCreateModal}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle aus Vorlage erstellen')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</Popup>
)}

View file

@ -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<string, React.ReactNode> = {
'page.admin.users': <FaUsers />,
'page.admin.invitations': <FaEnvelopeOpenText />,
'page.admin.mandates': <FaBuilding />,
'page.admin.roles': <FaKey />,
'page.admin.userRoleTemplates': <FaUserTag />,
'page.admin.role-permissions': <FaShieldAlt />,
'page.admin.user-mandates': <FaUserTag />,
'page.admin.userMandates': <FaUserTag />,

View file

@ -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<Role[]> => {
// 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<Role[]> => {
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<string, string> },
): Promise<{ success: boolean; data?: Role; error?: string }> => {
setLoading(true);
setError(null);
try {
const headers: Record<string, string> = { '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,

View file

@ -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<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
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(() => {
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<string, string>; 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 (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Rollen')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie systemweite und globale')}</p>
</div>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/feature-roles')}
>
<FaCube /> {t('Feature Rollen & Rechte')}
</button>
</div>
</div>
{/* Mandate Selector and Filters */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
{t('Mandant')}:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{mandateDisplayLabel(m)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Filter')}</label>
<select
className={styles.filterSelect}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
style={{ minWidth: 150 }}
>
<option value="mandate">{t('Mandanten-Rollen')}</option>
<option value="all">{t('Alle inkl. Templates')}</option>
<option value="global">{t('Nur Templates')}</option>
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles(selectedMandateId, { scopeFilter })}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neue Rolle')}
</button>
</div>
)}
</div>
{/* Info Box */}
{selectedMandateId && (
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>{t('System-Templates')}</strong>{' '}
{t('(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. Templates selbst können nicht gelöscht werden.')}{' '}
<strong>{t('Mandanten-Rollen')}</strong>{' '}
{t('gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.')}
</span>
</div>
)}
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.')}
</p>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/rbac/roles"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Rolle bearbeiten'),
disabled: (row: Role) => 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')}
/>
</div>
)}
{/* Create Role Modal */}
{showCreateModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Rolle erstellen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle erstellen')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
{/* Edit Role Modal */}
{editingRole && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rolle bearbeiten')}: {editingRole.roleLabel}
</h2>
<button
className={styles.modalClose}
onClick={() => setEditingRole(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{editFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
{t('Geltungsbereich')}:{' '}
<strong>
{editingRole.mandateId ? t('Mandanten-Instanz') : t('Template (global)')}
</strong>{' '}
({t('kann nicht geändert werden')})
</span>
</div>
<FormGeneratorForm
attributes={editFields}
data={editingRole}
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminMandateRolesPage;

View file

@ -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<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
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<string, string>;
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<string, string> }) => {
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<boolean> => {
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 (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button type="button" className={styles.secondaryButton} onClick={() => fetchUserRoleTemplates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('User Role Templates')}</h1>
<p className={styles.pageSubtitle}>
{t('Verwalten Sie System- und globale Rollen-Vorlagen. Diese werden bei neuen Mandanten kopiert.')}
</p>
</div>
</div>
<div className={styles.filterSection}>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => fetchUserRoleTemplates()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neues Rollen-Template')}
</button>
</div>
</div>
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>{t('System-Templates')}</strong>{' '}
{t(
'(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert und können hier nicht gelöscht werden.',
)}{' '}
<strong>{t('Globale Templates')}</strong>{' '}
{t('gelten für alle neuen Mandanten. Mandanten-spezifische Rollen verwalten Sie unter Mandanten.')}
</span>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/rbac/roles"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Rollen-Template bearbeiten'),
disabled: (row: Role) =>
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')}
/>
</div>
{showCreateModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neues Rollen-Template erstellen')}</h2>
<button type="button" className={styles.modalClose} onClick={() => setShowCreateModal(false)}>
</button>
</div>
<div className={styles.modalContent}>
{createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateTemplate}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rollen-Template erstellen')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
{editingRole && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rollen-Template bearbeiten')}: {editingRole.roleLabel}
</h2>
<button type="button" className={styles.modalClose} onClick={() => setEditingRole(null)}>
</button>
</div>
<div className={styles.modalContent}>
{editFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
{t('Geltungsbereich')}:{' '}
<strong>
{editingRole.isSystemRole ? t('System-Template') : t('Template (global)')}
</strong>{' '}
({t('kann nicht geändert werden')})
</span>
</div>
<FormGeneratorForm
attributes={editFields}
data={editingRole}
mode="edit"
onSubmit={handleEditTemplate}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminUserRoleTemplatesPage;

View file

@ -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';