531 lines
19 KiB
TypeScript
531 lines
19 KiB
TypeScript
/**
|
||
* 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, FaGlobe, FaShieldAlt, FaCube } from 'react-icons/fa';
|
||
import { useToast } from '../../contexts/ToastContext';
|
||
import api from '../../api';
|
||
import styles from './Admin.module.css';
|
||
|
||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||
|
||
export const AdminMandateRolesPage: React.FC = () => {
|
||
const { t } = useLanguage();
|
||
|
||
const navigate = useNavigate();
|
||
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();
|
||
// Fetch Role attributes from backend
|
||
api.get('/api/attributes/Role').then(response => {
|
||
const attrs = response.data?.attributes || response.data || [];
|
||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||
}).catch(() => setBackendAttributes([]));
|
||
}, [fetchMandates]);
|
||
|
||
// 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]);
|
||
|
||
// Get description text
|
||
const getDescriptionText = (desc: string | { [key: string]: string } | undefined) => {
|
||
if (!desc) return '-';
|
||
if (typeof desc === 'string') return desc;
|
||
return desc.de || desc.en || Object.values(desc)[0] || '-';
|
||
};
|
||
|
||
// Table columns - scopeType is now a backend-computed field
|
||
const columns = useMemo(() => [
|
||
{
|
||
key: 'roleLabel',
|
||
label: t('Bezeichnung'),
|
||
type: 'string' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
searchable: true,
|
||
width: 150
|
||
},
|
||
{
|
||
key: 'description',
|
||
label: t('Beschreibung'),
|
||
type: 'string' as const,
|
||
sortable: false,
|
||
filterable: false,
|
||
width: 250,
|
||
formatter: (value: string | { [key: string]: string }) => getDescriptionText(value)
|
||
},
|
||
{
|
||
key: 'scopeType',
|
||
label: t('Geltungsbereich'),
|
||
type: 'string' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
width: 140,
|
||
formatter: (value: string) => {
|
||
if (value === 'system') {
|
||
return (
|
||
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
||
<FaUserShield style={{ marginRight: 4 }} /> System-Template
|
||
</span>
|
||
);
|
||
}
|
||
if (value === 'global') {
|
||
return (
|
||
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
||
<FaGlobe style={{ marginRight: 4 }} /> Template
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
|
||
<FaBuilding style={{ marginRight: 4 }} /> Mandant
|
||
</span>
|
||
);
|
||
}
|
||
},
|
||
], [t]);
|
||
|
||
// 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]);
|
||
|
||
// 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?: 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: description,
|
||
mandateId: data.scope === 'mandate' ? selectedMandateId : undefined
|
||
};
|
||
|
||
const result = await createRole(roleData, selectedMandateId);
|
||
|
||
if (result.success) {
|
||
setShowCreateModal(false);
|
||
await fetchRoles(selectedMandateId, { scopeFilter });
|
||
} else {
|
||
showError('Fehler', result.error || 'Fehler beim Erstellen der Rolle');
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Create role error:', err);
|
||
showError('Fehler', err.message || '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 {
|
||
// 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 = {
|
||
roleLabel: data.roleLabel,
|
||
description: 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('Fehler', result.error || 'Fehler beim Aktualisieren der Rolle');
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Update role error:', err);
|
||
showError('Fehler', err.message || 'Fehler beim Aktualisieren der Rolle');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Handle delete role (confirmation handled by DeleteActionButton)
|
||
const handleDeleteRole = async (role: Role) => {
|
||
if (role.isSystemRole) {
|
||
showWarning('Nicht erlaubt', '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('Fehler', result.error || 'Fehler beim Löschen der Rolle');
|
||
}
|
||
};
|
||
|
||
// Handle edit click
|
||
const handleEditClick = (role: Role) => {
|
||
setEditingRole(role);
|
||
};
|
||
|
||
// Get mandate name
|
||
const getMandateName = (mandate: Mandate) => {
|
||
if (mandate.label) return mandate.label;
|
||
if (typeof mandate.name === 'object') {
|
||
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
|
||
}
|
||
return mandate.name || mandate.id;
|
||
};
|
||
|
||
if (error && !selectedMandateId) {
|
||
return (
|
||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||
<div className={styles.errorContainer}>
|
||
<span className={styles.errorIcon}>⚠️</span>
|
||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
||
<FaSync /> 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/mandate-role-permissions')}
|
||
>
|
||
<FaShieldAlt /> Rollen-Berechtigungen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
onClick={() => navigate('/admin/feature-roles')}
|
||
>
|
||
<FaCube /> 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 }} />
|
||
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}>
|
||
{getMandateName(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">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' : ''} /> Aktualisieren
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => setShowCreateModal(true)}
|
||
>
|
||
<FaPlus /> Neue Rolle
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info Box */}
|
||
{selectedMandateId && (
|
||
<div className={styles.infoBox}>
|
||
<FaUserShield style={{ marginRight: 8 }} />
|
||
<span>
|
||
<strong>System-Templates</strong> (admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert.
|
||
Templates selbst können nicht gelöscht werden.
|
||
<strong> Mandanten-Rollen</strong> 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}>
|
||
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={false}
|
||
actionButtons={[
|
||
{
|
||
type: 'edit' as const,
|
||
onAction: handleEditClick,
|
||
title: t('Rolle bearbeiten'),
|
||
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: '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: '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} onClick={() => setShowCreateModal(false)}>
|
||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||
<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 ? 'Erstelle...' : 'Rolle erstellen'}
|
||
cancelButtonText={t('Abbrechen')}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit Role Modal */}
|
||
{editingRole && (
|
||
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
|
||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>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>
|
||
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandanten-Instanz' : 'Template (global)'}</strong>
|
||
{' '}(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;
|