frontend_nyla/src/pages/admin/AdminMandateRolesPage.tsx
2026-04-11 00:07:30 +02:00

531 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;