frontend_nyla/src/pages/admin/AdminFeatureRolesPage.tsx
2026-01-24 09:58:04 +01:00

491 lines
16 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.

/**
* AdminFeatureRolesPage
*
* Admin page for managing FEATURE TEMPLATE ROLES.
* These are roles that are copied to new feature instances.
*
* According to admin_ui_concept.md:
* - Filter: featureCode!=null AND mandateId=null AND featureInstanceId=null
* - View: Per feature (dropdown selection)
* - Actions: Create feature role, edit description, manage AccessRules, delete role
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { AccessRulesEditor } from '../../components/AccessRules';
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
interface Feature {
id?: string;
code: string; // Backend uses 'code' not 'featureCode'
featureCode?: string; // Alias for backward compatibility
label: string | { [key: string]: string }; // Backend uses 'label' not 'name'
name?: string | { [key: string]: string }; // Alias for backward compatibility
description?: string | { [key: string]: string };
icon?: string;
}
interface FeatureRole {
id: string;
roleLabel: string;
description?: { [key: string]: string };
featureCode: string;
mandateId?: string | null;
featureInstanceId?: string | null;
isSystemRole?: boolean;
}
export const AdminFeatureRolesPage: React.FC = () => {
// State
const [features, setFeatures] = useState<Feature[]>([]);
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
const [roles, setRoles] = useState<FeatureRole[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<FeatureRole | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
// Load features on mount
useEffect(() => {
const loadFeatures = async () => {
try {
const response = await api.get('/api/features/');
const featureList = response.data?.items || response.data || [];
setFeatures(Array.isArray(featureList) ? featureList : []);
// Auto-select first feature if available
if (featureList.length > 0 && !selectedFeatureCode) {
setSelectedFeatureCode(featureList[0].code || featureList[0].featureCode || '');
}
} catch (err: any) {
console.error('Error loading features:', err);
setError('Fehler beim Laden der Features');
}
};
loadFeatures();
}, []);
// Load roles when feature changes
const fetchRoles = useCallback(async () => {
if (!selectedFeatureCode) {
setRoles([]);
return;
}
setLoading(true);
setError(null);
try {
const response = await api.get(`/api/features/templates/roles`, {
params: { featureCode: selectedFeatureCode }
});
const roleList = response.data || [];
setRoles(Array.isArray(roleList) ? roleList : []);
} catch (err: any) {
console.error('Error loading feature roles:', err);
setError('Fehler beim Laden der Feature-Rollen');
setRoles([]);
} finally {
setLoading(false);
}
}, [selectedFeatureCode]);
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
// Get text from multilingual object
const getTextValue = (value: string | { [key: string]: string } | undefined): string => {
if (!value) return '-';
if (typeof value === 'string') return value;
return value.de || value.en || Object.values(value)[0] || '-';
};
// Table columns
const columns = useMemo(() => [
{
key: 'roleLabel',
label: 'Rollen-Label',
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
width: 180
},
{
key: 'description',
label: 'Beschreibung',
type: 'string' as const,
sortable: false,
width: 300,
formatter: (value: string | { [key: string]: string }) => getTextValue(value)
},
{
key: 'featureCode',
label: 'Feature',
type: 'string' as const,
sortable: true,
filterable: true,
width: 120,
formatter: (value: string) => (
<span className={styles.badge} style={{ background: 'var(--primary-color, #4a5568)', color: 'white' }}>
<FaCube style={{ marginRight: 4 }} /> {value}
</span>
)
},
], []);
// Form attributes for create
const createFields: AttributeDefinition[] = useMemo(() => {
const fields: AttributeDefinition[] = [
{
name: 'roleLabel',
label: 'Rollen-Label',
type: 'string',
required: true,
description: 'Eindeutiger Bezeichner der Rolle (z.B. trustee-admin)'
},
{
name: 'description',
label: 'Beschreibung',
type: 'multilingual',
required: false,
description: 'Mehrsprachige Beschreibung der Rolle'
}
];
return fields;
}, []);
// Form attributes for edit
const editFields: AttributeDefinition[] = useMemo(() => {
return [
{
name: 'roleLabel',
label: 'Rollen-Label',
type: 'string',
required: true,
readonly: true, // Label should not be changed after creation
description: 'Eindeutiger Bezeichner der Rolle'
},
{
name: 'description',
label: 'Beschreibung',
type: 'multilingual',
required: false,
description: 'Mehrsprachige Beschreibung der Rolle'
}
];
}, []);
// Handle create role
const handleCreateRole = async (data: { roleLabel: string; description?: { [key: string]: string } }) => {
if (!selectedFeatureCode) return;
setIsSubmitting(true);
try {
const params = new URLSearchParams();
params.append('roleLabel', data.roleLabel);
params.append('featureCode', selectedFeatureCode);
await api.post(`/api/features/templates/roles?${params.toString()}`, data.description || {});
setShowCreateModal(false);
await fetchRoles();
} catch (err: any) {
console.error('Error creating role:', err);
alert(err.response?.data?.detail || 'Fehler beim Erstellen der Rolle');
} finally {
setIsSubmitting(false);
}
};
// Handle edit role
const handleEditRole = async (data: { roleLabel: string; description?: { [key: string]: string } }) => {
if (!editingRole) return;
setIsSubmitting(true);
try {
await api.put(`/api/rbac/roles/${editingRole.id}`, {
description: data.description
});
setEditingRole(null);
await fetchRoles();
} catch (err: any) {
console.error('Error updating role:', err);
alert(err.response?.data?.detail || 'Fehler beim Aktualisieren der Rolle');
} finally {
setIsSubmitting(false);
}
};
// Handle delete role
const handleDeleteRole = async (role: FeatureRole) => {
if (window.confirm(`Möchten Sie die Rolle "${role.roleLabel}" wirklich löschen?`)) {
try {
await api.delete(`/api/rbac/roles/${role.id}`);
await fetchRoles();
} catch (err: any) {
console.error('Error deleting role:', err);
alert(err.response?.data?.detail || 'Fehler beim Löschen der Rolle');
}
}
};
// Handle edit click
const handleEditClick = (role: FeatureRole) => {
setEditingRole(role);
};
// Get feature name - Backend uses 'label' field
const getFeatureName = (feature: Feature) => getTextValue(feature.label || feature.name);
if (error && !selectedFeatureCode) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{error}</p>
<button className={styles.secondaryButton} onClick={() => window.location.reload()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature-Rollen</h1>
<p className={styles.pageSubtitle}>Template-Rollen für Feature-Instanzen verwalten</p>
</div>
</div>
{/* Feature Selector */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Feature:
</label>
<select
className={styles.filterSelect}
value={selectedFeatureCode}
onChange={(e) => setSelectedFeatureCode(e.target.value)}
>
<option value="">-- Feature wählen --</option>
{features.map(f => {
const featureCode = f.code || f.featureCode || '';
return (
<option key={featureCode} value={featureCode}>
{getFeatureName(f)} ({featureCode})
</option>
);
})}
</select>
</div>
{selectedFeatureCode && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Feature-Rolle
</button>
</div>
)}
</div>
{/* Info Box */}
{selectedFeatureCode && (
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>Feature-Template-Rollen</strong> werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert.
Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus.
</span>
</div>
)}
{/* Content */}
{!selectedFeatureCode ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Feature ausgewählt</h3>
<p className={styles.emptyDescription}>
Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.
</p>
</div>
) : loading && roles.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Feature-Rollen...</span>
</div>
) : roles.length === 0 ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
<p className={styles.emptyDescription}>
Es gibt noch keine Template-Rollen für dieses Feature.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Rolle erstellen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rolle bearbeiten',
},
{
type: 'delete' as const,
title: 'Rolle löschen',
}
]}
customActions={[
{
id: 'permissions',
icon: <FaShieldAlt />,
onClick: (role: FeatureRole) => setPermissionsRole(role),
title: 'Berechtigungen verwalten',
}
]}
onDelete={handleDeleteRole}
hookData={{
refetch: fetchRoles,
handleDelete: handleDeleteRole,
}}
emptyMessage="Keine Feature-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}>Neue Feature-Rolle erstellen</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{selectedFeatureCode}</strong></span>
</div>
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? 'Erstelle...' : 'Rolle erstellen'}
cancelButtonText="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}>Feature-Rolle bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingRole(null)}
>
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{editingRole.featureCode}</strong></span>
</div>
<FormGeneratorForm
attributes={editFields}
data={editingRole}
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
</div>
</div>
</div>
)}
{/* Permissions Modal */}
{permissionsRole && (
<div className={styles.modalOverlay} onClick={() => setPermissionsRole(null)}>
<div className={styles.modal} style={{ maxWidth: '900px', width: '90%' }} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
<FaShieldAlt style={{ marginRight: 8 }} />
Berechtigungen: {permissionsRole.roleLabel}
</h2>
<button
className={styles.modalClose}
onClick={() => setPermissionsRole(null)}
>
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{permissionsRole.featureCode}</strong></span>
<span style={{ marginLeft: '1rem' }}>Template-Rolle (global)</span>
</div>
<AccessRulesEditor
roleId={permissionsRole.id}
roleName={permissionsRole.roleLabel}
isTemplate={true}
onSave={() => setPermissionsRole(null)}
/>
</div>
</div>
</div>
)}
</div>
);
};
export default AdminFeatureRolesPage;