491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
/**
|
||
* 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;
|