502 lines
17 KiB
TypeScript
502 lines
17 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 { useToast } from '../../contexts/ToastContext';
|
||
import api from '../../api';
|
||
import styles from './Admin.module.css';
|
||
|
||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||
|
||
interface Feature {
|
||
id?: string;
|
||
code: string;
|
||
featureCode?: string;
|
||
label: string;
|
||
name?: string;
|
||
description?: string;
|
||
icon?: string;
|
||
}
|
||
|
||
interface FeatureRole {
|
||
id: string;
|
||
roleLabel: string;
|
||
description?: string;
|
||
featureCode: string;
|
||
mandateId?: string | null;
|
||
featureInstanceId?: string | null;
|
||
isSystemRole?: boolean;
|
||
}
|
||
|
||
export const AdminFeatureRolesPage: React.FC = () => {
|
||
const { t } = useLanguage();
|
||
|
||
const { showError } = useToast();
|
||
// 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(t('Fehler beim Laden der Features'));
|
||
}
|
||
};
|
||
loadFeatures();
|
||
|
||
}, []);
|
||
|
||
const [pagination, setPagination] = useState<any>(null);
|
||
|
||
// Load roles when feature changes
|
||
const fetchRoles = useCallback(async (params?: any) => {
|
||
if (!selectedFeatureCode) {
|
||
setRoles([]);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const requestParams: Record<string, string> = { featureCode: selectedFeatureCode };
|
||
if (params && typeof params === 'object') {
|
||
const paginationObj: any = {};
|
||
if (params.page !== undefined) paginationObj.page = params.page;
|
||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||
if (params.sort) paginationObj.sort = params.sort;
|
||
if (params.filters) paginationObj.filters = params.filters;
|
||
if (params.search) paginationObj.search = params.search;
|
||
if (Object.keys(paginationObj).length > 0) {
|
||
requestParams.pagination = JSON.stringify(paginationObj);
|
||
}
|
||
}
|
||
const response = await api.get(`/api/features/templates/roles`, { params: requestParams });
|
||
const data = response.data;
|
||
if (data && typeof data === 'object' && 'items' in data) {
|
||
setRoles(Array.isArray(data.items) ? data.items : []);
|
||
if (data.pagination) setPagination(data.pagination);
|
||
} else {
|
||
setRoles(Array.isArray(data) ? data : []);
|
||
setPagination(null);
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Error loading feature roles:', err);
|
||
setError(t('Fehler beim Laden der Feature-Rollen'));
|
||
setRoles([]);
|
||
setPagination(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [selectedFeatureCode, t]);
|
||
|
||
useEffect(() => {
|
||
fetchRoles();
|
||
}, [fetchRoles]);
|
||
|
||
const getTextValue = (value: any): string => {
|
||
if (!value) return '-';
|
||
if (typeof value === 'string') return value;
|
||
return String(value);
|
||
};
|
||
|
||
// Table columns
|
||
const columns = useMemo(() => [
|
||
{
|
||
key: 'roleLabel',
|
||
label: t('Rollen-Label'),
|
||
type: 'string' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
searchable: true,
|
||
width: 180
|
||
},
|
||
{
|
||
key: 'description',
|
||
label: t('Beschreibung'),
|
||
type: 'string' as const,
|
||
sortable: false,
|
||
width: 300,
|
||
formatter: (value: string) => getTextValue(value)
|
||
},
|
||
{
|
||
key: 'featureCode',
|
||
label: t('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>
|
||
)
|
||
},
|
||
], [t]);
|
||
|
||
// Form attributes for create
|
||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||
const fields: AttributeDefinition[] = [
|
||
{
|
||
name: 'roleLabel',
|
||
label: t('Rollen-Label'),
|
||
type: 'string',
|
||
required: true,
|
||
description: t('Rollen-Label Beschreibung')
|
||
},
|
||
{
|
||
name: 'description',
|
||
label: t('Beschreibung'),
|
||
type: 'textarea',
|
||
required: false,
|
||
description: t('Beschreibung der Rolle')
|
||
}
|
||
];
|
||
return fields;
|
||
}, [t]);
|
||
|
||
// Form attributes for edit
|
||
const editFields: AttributeDefinition[] = useMemo(() => {
|
||
return [
|
||
{
|
||
name: 'roleLabel',
|
||
label: t('Rollen-Label'),
|
||
type: 'string',
|
||
required: true,
|
||
readonly: true,
|
||
description: t('Rollen-Label (nur lesen)')
|
||
},
|
||
{
|
||
name: 'description',
|
||
label: t('Beschreibung'),
|
||
type: 'textarea',
|
||
required: false,
|
||
description: t('Beschreibung der Rolle')
|
||
}
|
||
];
|
||
}, [t]);
|
||
|
||
// Handle create role
|
||
const handleCreateRole = async (data: { roleLabel: string; description?: 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);
|
||
showError(t('Fehler'), err.response?.data?.detail || t('Fehler beim Erstellen der Rolle'));
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Handle edit role
|
||
const handleEditRole = async (data: { roleLabel: string; description?: 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);
|
||
showError(t('Fehler'), err.response?.data?.detail || t('Fehler beim Aktualisieren der Rolle'));
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Handle delete role (confirmation handled by DeleteActionButton)
|
||
const handleDeleteRole = async (role: FeatureRole) => {
|
||
try {
|
||
await api.delete(`/api/rbac/roles/${role.id}`);
|
||
await fetchRoles();
|
||
} catch (err: any) {
|
||
console.error('Error deleting role:', err);
|
||
showError(t('Fehler'), err.response?.data?.detail || t('Fehler beim Löschen der Rolle'));
|
||
}
|
||
};
|
||
|
||
// Handle edit click
|
||
const handleEditClick = (role: FeatureRole) => {
|
||
setEditingRole(role);
|
||
};
|
||
|
||
const getFeatureName = (feature: Feature) => {
|
||
return feature.label || feature.name || '-';
|
||
};
|
||
|
||
if (error && !selectedFeatureCode) {
|
||
return (
|
||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||
<div className={styles.errorContainer}>
|
||
<span className={styles.errorIcon}>⚠️</span>
|
||
<p className={styles.errorMessage}>{error}</p>
|
||
<button className={styles.secondaryButton} onClick={() => window.location.reload()}>
|
||
<FaSync /> {t('Erneut versuchen')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||
<div className={styles.pageHeader}>
|
||
<div>
|
||
<h1 className={styles.pageTitle}>{t('Feature-Rollen-Rechte')}</h1>
|
||
<p className={styles.pageSubtitle}>{t('Template-Rollen und deren Berechtigungen für')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Feature Selector */}
|
||
<div className={styles.filterSection}>
|
||
<div className={styles.filterGroup}>
|
||
<label className={styles.filterLabel}>
|
||
<FaCube style={{ marginRight: 8 }} />
|
||
{t('Feature:')}
|
||
</label>
|
||
<select
|
||
className={styles.filterSelect}
|
||
value={selectedFeatureCode}
|
||
onChange={(e) => setSelectedFeatureCode(e.target.value)}
|
||
>
|
||
<option value="">{t('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' : ''} /> {t('Aktualisieren')}
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => setShowCreateModal(true)}
|
||
>
|
||
<FaPlus /> {t('Neue Feature-Rolle')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info Box */}
|
||
{selectedFeatureCode && (
|
||
<div className={styles.infoBox}>
|
||
<FaUserShield style={{ marginRight: 8 }} />
|
||
<span>
|
||
<strong>{t('Feature-Template-Rollen')}</strong>{' '}
|
||
{t('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}>{t('Kein Feature ausgewählt')}</h3>
|
||
<p className={styles.emptyDescription}>
|
||
{t('Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.')}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className={styles.tableContainer}>
|
||
<FormGeneratorTable
|
||
data={roles}
|
||
columns={columns}
|
||
apiEndpoint="/api/features/templates/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'),
|
||
},
|
||
{
|
||
type: 'delete' as const,
|
||
title: t('Rolle löschen'),
|
||
}
|
||
]}
|
||
customActions={[
|
||
{
|
||
id: 'permissions',
|
||
icon: <FaShieldAlt />,
|
||
onClick: (role: FeatureRole) => setPermissionsRole(role),
|
||
title: t('Berechtigungen verwalten'),
|
||
}
|
||
]}
|
||
onDelete={handleDeleteRole}
|
||
hookData={{
|
||
refetch: fetchRoles,
|
||
pagination,
|
||
handleDelete: handleDeleteRole,
|
||
}}
|
||
emptyMessage={t('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}>{t('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>
|
||
{t('Feature')}: <strong>{selectedFeatureCode}</strong>
|
||
</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} onClick={() => setEditingRole(null)}>
|
||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>{t('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>{t('Feature:')} <strong>{editingRole.featureCode}</strong></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>
|
||
)}
|
||
|
||
{/* 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 }} />
|
||
{t('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>
|
||
{t('Feature')}: <strong>{permissionsRole.featureCode}</strong>
|
||
</span>
|
||
<span style={{ marginLeft: '1rem' }}>{t('Template-Rolle global')}</span>
|
||
</div>
|
||
<AccessRulesEditor
|
||
roleId={permissionsRole.id}
|
||
roleName={permissionsRole.roleLabel}
|
||
isTemplate={true}
|
||
onSave={() => setPermissionsRole(null)}
|
||
featureCode={permissionsRole.featureCode}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminFeatureRolesPage;
|