frontend_nyla/src/pages/admin/AdminFeatureInstanceUsersPage.tsx
2026-01-25 03:01:07 +01:00

626 lines
22 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.

/**
* AdminFeatureInstanceUsersPage
*
* Admin page for managing user access to feature instances.
* Allows adding, removing, and updating user roles within feature instances.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useFeatureAccess, type FeatureAccessUser, type FeatureInstanceRole, type PaginationParams, type PaginationMetadata } from '../../hooks/useFeatureAccess';
import { useUserMandates } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaBuilding, FaCube } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
export const AdminFeatureInstanceUsersPage: React.FC = () => {
const {
features,
instances,
loading,
error,
fetchFeatures,
fetchInstanceUsers,
addUserToInstance,
removeUserFromInstance,
updateInstanceUserRoles,
fetchInstanceRoles,
} = useFeatureAccess();
const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast();
// Combined instance option type
interface CombinedInstanceOption {
mandateId: string;
instanceId: string;
mandateName: string;
instanceLabel: string;
featureCode: string;
combinedKey: string; // mandateId:instanceId for unique identification
}
// State
const [combinedOptions, setCombinedOptions] = useState<CombinedInstanceOption[]>([]);
const [selectedCombinedKey, setSelectedCombinedKey] = useState<string>('');
const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]);
const [instanceRoles, setInstanceRoles] = useState<FeatureInstanceRole[]>([]);
const [allUsers, setAllUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState<FeatureAccessUser | null>(null);
const [, setIsSubmitting] = useState(false);
const [usersLoading, setUsersLoading] = useState(false);
const [usersPagination, setUsersPagination] = useState<PaginationMetadata | null>(null);
// Extract mandateId and instanceId from combined key
const selectedMandateId = useMemo(() => {
if (!selectedCombinedKey) return '';
return selectedCombinedKey.split(':')[0] || '';
}, [selectedCombinedKey]);
const selectedInstanceId = useMemo(() => {
if (!selectedCombinedKey) return '';
return selectedCombinedKey.split(':')[1] || '';
}, [selectedCombinedKey]);
// Load mandates and features on mount, then build combined options
useEffect(() => {
fetchFeatures();
const loadCombinedOptions = async () => {
const loadedMandates = await fetchMandates();
// Load instances for all mandates in parallel
const allOptions: CombinedInstanceOption[] = [];
for (const mandate of loadedMandates) {
try {
const response = await api.get('/api/features/instances', {
headers: { 'X-Mandate-Id': mandate.id }
});
const instanceList = response.data?.items || response.data || [];
if (Array.isArray(instanceList)) {
for (const inst of instanceList) {
allOptions.push({
mandateId: mandate.id,
instanceId: inst.id,
mandateName: typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id),
instanceLabel: inst.label || inst.id,
featureCode: inst.featureCode,
combinedKey: `${mandate.id}:${inst.id}`,
});
}
}
} catch (err) {
console.error(`Error loading instances for mandate ${mandate.id}:`, err);
}
}
// Sort by mandate name, then by instance label
allOptions.sort((a, b) => {
const mandateCompare = a.mandateName.localeCompare(b.mandateName);
if (mandateCompare !== 0) return mandateCompare;
return a.instanceLabel.localeCompare(b.instanceLabel);
});
setCombinedOptions(allOptions);
};
loadCombinedOptions();
}, [fetchFeatures, fetchMandates]);
// Load users and roles when instance changes
useEffect(() => {
if (selectedMandateId && selectedInstanceId) {
setUsersLoading(true);
Promise.all([
fetchInstanceUsers(selectedMandateId, selectedInstanceId),
fetchInstanceRoles(selectedMandateId, selectedInstanceId),
]).then(([users, roles]) => {
setInstanceUsers(users);
setInstanceRoles(roles);
}).finally(() => {
setUsersLoading(false);
});
}
}, [selectedMandateId, selectedInstanceId, fetchInstanceUsers, fetchInstanceRoles]);
// Load mandate members for the add modal (only users who are members of the selected mandate)
useEffect(() => {
if (!selectedMandateId) {
setAllUsers([]);
return;
}
api.get(`/api/mandates/${selectedMandateId}/users`).then(response => {
const data = response.data?.items || response.data || [];
// Map MandateUserInfo to the expected format
const mappedUsers = Array.isArray(data) ? data.map((u: any) => ({
id: u.userId,
username: u.username,
email: u.email,
fullName: u.fullName
})) : [];
setAllUsers(mappedUsers);
}).catch(() => setAllUsers([]));
}, [selectedMandateId]);
// Refresh instance users with optional pagination
const refreshUsers = useCallback(async (paginationParams?: PaginationParams) => {
if (selectedMandateId && selectedInstanceId) {
setUsersLoading(true);
try {
// Build query params
const params = new URLSearchParams();
if (paginationParams && Object.keys(paginationParams).length > 0) {
params.append('pagination', JSON.stringify(paginationParams));
}
const url = params.toString()
? `/api/features/instances/${selectedInstanceId}/users?${params.toString()}`
: `/api/features/instances/${selectedInstanceId}/users`;
const response = await api.get(url, {
headers: { 'X-Mandate-Id': selectedMandateId }
});
if (response.data?.items && Array.isArray(response.data.items)) {
setInstanceUsers(response.data.items);
if (response.data.pagination) {
setUsersPagination(response.data.pagination);
}
} else {
const users = Array.isArray(response.data) ? response.data : [];
setInstanceUsers(users);
}
} catch (err) {
console.error('Error refreshing users:', err);
setInstanceUsers([]);
} finally {
setUsersLoading(false);
}
}
}, [selectedMandateId, selectedInstanceId]);
// Get users not yet in the instance
const availableUsers = useMemo(() => {
const existingUserIds = new Set(instanceUsers.map(u => u.userId));
return allUsers.filter(u => !existingUserIds.has(u.id));
}, [allUsers, instanceUsers]);
// Table columns
const columns = useMemo(() => [
{
key: 'username',
label: 'Benutzername',
type: 'text' as const,
sortable: true,
filterable: true,
searchable: true,
width: 150,
},
{
key: 'email',
label: 'E-Mail',
type: 'text' as const,
sortable: true,
filterable: true,
searchable: true,
width: 200,
},
{
key: 'fullName',
label: 'Vollständiger Name',
type: 'text' as const,
sortable: true,
filterable: true,
searchable: true,
width: 180,
},
{
key: 'roleLabels',
label: 'Rollen',
type: 'text' as const,
sortable: false,
filterable: false,
searchable: true,
width: 200,
render: (value: string[]) => {
if (!value || value.length === 0) return '-';
return value.join(', ');
},
},
{
key: 'enabled',
label: 'Aktiv',
type: 'boolean' as const,
sortable: true,
filterable: true,
searchable: false,
width: 80,
},
], []);
// Dynamic options for forms (users and roles)
const userOptions = useMemo(() =>
availableUsers.map(u => ({
value: u.id,
label: `${u.username} ${u.email ? `(${u.email})` : ''}`
})), [availableUsers]);
const roleOptions = useMemo(() =>
instanceRoles.map(r => ({
value: r.id,
label: r.roleLabel
})), [instanceRoles]);
// Form attributes for adding a user
const addUserFields: AttributeDefinition[] = useMemo(() => {
return [
{
name: 'userId',
label: 'Benutzer',
type: 'enum' as const,
required: true,
options: userOptions,
},
{
name: 'roleIds',
label: 'Rollen',
type: 'multiselect' as const,
required: true,
options: roleOptions,
}
];
}, [userOptions, roleOptions]);
// Form attributes for editing user roles
const editRolesFields: AttributeDefinition[] = useMemo(() => {
return [{
name: 'roleIds',
label: 'Rollen',
type: 'multiselect' as const,
required: true,
options: roleOptions,
}];
}, [roleOptions]);
// Handle add user submit
const handleAddUser = async (data: { userId: string; roleIds: string[] }) => {
if (!selectedMandateId || !selectedInstanceId) return;
setIsSubmitting(true);
try {
const result = await addUserToInstance(selectedMandateId, selectedInstanceId, data);
if (result.success) {
setShowAddModal(false);
refreshUsers();
showSuccess('Benutzer hinzugefügt', 'Der Benutzer wurde erfolgreich zur Feature-Instanz hinzugefügt.');
} else {
showError('Fehler', result.error || 'Fehler beim Hinzufügen des Benutzers');
}
} finally {
setIsSubmitting(false);
}
};
// Handle edit roles submit
const handleEditRoles = async (data: { roleIds: string[] }) => {
if (!selectedMandateId || !selectedInstanceId || !editingUser) return;
setIsSubmitting(true);
try {
const result = await updateInstanceUserRoles(
selectedMandateId,
selectedInstanceId,
editingUser.userId,
data.roleIds
);
if (result.success) {
setEditingUser(null);
refreshUsers();
showSuccess('Rollen aktualisiert', 'Die Benutzerrollen wurden erfolgreich aktualisiert.');
} else {
showError('Fehler', result.error || 'Fehler beim Aktualisieren der Rollen');
}
} finally {
setIsSubmitting(false);
}
};
// Handle remove user
const handleRemoveUser = async (user: FeatureAccessUser) => {
if (!selectedMandateId || !selectedInstanceId) return;
if (window.confirm(`Möchten Sie den Benutzer "${user.username}" wirklich aus dieser Feature-Instanz entfernen?`)) {
const result = await removeUserFromInstance(selectedMandateId, selectedInstanceId, user.userId);
if (result.success) {
refreshUsers();
showSuccess('Benutzer entfernt', `"${user.username}" wurde aus der Feature-Instanz entfernt.`);
} else {
showError('Fehler', result.error || 'Fehler beim Entfernen des Benutzers');
}
}
};
// Handle edit click
const handleEditClick = (user: FeatureAccessUser) => {
setEditingUser(user);
};
// Get feature label
const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code);
if (feature) {
return typeof feature.label === 'object'
? (feature.label.de || feature.label.en || code)
: (feature.label || code);
}
return code;
};
// Get selected instance info from combined options
const selectedInstance = useMemo(() => {
const option = combinedOptions.find(o => o.combinedKey === selectedCombinedKey);
if (!option) return null;
return instances.find(i => i.id === option.instanceId) || {
id: option.instanceId,
label: option.instanceLabel,
featureCode: option.featureCode,
mandateId: option.mandateId,
};
}, [combinedOptions, selectedCombinedKey, instances]);
// Get selected combined option for display
const selectedOption = useMemo(() => {
return combinedOptions.find(o => o.combinedKey === selectedCombinedKey);
}, [combinedOptions, selectedCombinedKey]);
if (error && !selectedCombinedKey) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature Instanz Benutzer</h1>
<p className={styles.pageSubtitle}>Verwalten Sie Benutzerzugriffe auf Feature-Instanzen</p>
</div>
</div>
{/* Combined Selector: Mandate + Feature Instance */}
<div className={styles.filterSection}>
<div className={styles.filterGroup} style={{ flex: 1, maxWidth: 500 }}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Mandant / Feature-Instanz:
</label>
<select
className={styles.filterSelect}
value={selectedCombinedKey}
onChange={(e) => setSelectedCombinedKey(e.target.value)}
disabled={loading || combinedOptions.length === 0}
>
<option value="">-- Mandant / Feature-Instanz wählen --</option>
{/* Group options by mandate */}
{(() => {
const groupedByMandate: Record<string, CombinedInstanceOption[]> = {};
combinedOptions.forEach(opt => {
if (!groupedByMandate[opt.mandateName]) {
groupedByMandate[opt.mandateName] = [];
}
groupedByMandate[opt.mandateName].push(opt);
});
return Object.entries(groupedByMandate).map(([mandateName, options]) => (
<optgroup key={mandateName} label={mandateName}>
{options.map(opt => (
<option key={opt.combinedKey} value={opt.combinedKey}>
{opt.instanceLabel} ({getFeatureLabel(opt.featureCode)})
</option>
))}
</optgroup>
));
})()}
</select>
</div>
{selectedCombinedKey && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refreshUsers()}
disabled={usersLoading}
>
<FaSync className={usersLoading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0 || instanceRoles.length === 0}
>
<FaPlus /> Benutzer hinzufügen
</button>
</div>
)}
</div>
{/* Info box when instance is selected */}
{selectedOption && (
<div className={styles.infoBox}>
<FaBuilding style={{ marginRight: 8 }} />
<span>Mandant: <strong>{selectedOption.mandateName}</strong></span>
<span style={{ margin: '0 16px', color: 'var(--color-border)' }}>|</span>
<FaCube style={{ marginRight: 8 }} />
<span>Instanz: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode})</span>
</div>
)}
{/* Roles info box */}
{selectedInstance && instanceRoles.length > 0 && (
<div className={styles.infoBox}>
<span>Verfügbare Rollen: </span>
{instanceRoles.map((r, i) => (
<span key={r.id}>
{i > 0 && ', '}
<strong>{r.roleLabel}</strong>
</span>
))}
</div>
)}
{/* Warning if no roles available */}
{selectedInstance && instanceRoles.length === 0 && !usersLoading && (
<div className={styles.warningBox || styles.infoBox}>
<span> </span>
<span>Diese Instanz hat noch keine Rollen. Bitte synchronisieren Sie die Rollen zuerst unter "Feature-Instanzen".</span>
</div>
)}
{/* Content */}
{!selectedCombinedKey ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Feature-Instanz ausgewählt</h3>
<p className={styles.emptyDescription}>
{combinedOptions.length === 0
? 'Es gibt noch keine Feature-Instanzen. Erstellen Sie zuerst Feature-Instanzen unter "Feature-Instanzen".'
: 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'}
</p>
</div>
) : usersLoading && instanceUsers.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Benutzer...</span>
</div>
) : instanceUsers.length === 0 ? (
<div className={styles.emptyState}>
<FaUsers className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Benutzer</h3>
<p className={styles.emptyDescription}>
Dieser Feature-Instanz sind noch keine Benutzer zugewiesen.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0 || instanceRoles.length === 0}
>
<FaPlus /> Ersten Benutzer hinzufügen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={instanceUsers}
columns={columns}
loading={usersLoading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rollen bearbeiten',
},
{
type: 'delete' as const,
title: 'Aus Instanz entfernen',
}
]}
onDelete={handleRemoveUser}
hookData={{
refetch: refreshUsers,
pagination: usersPagination,
handleDelete: async (featureAccessId: string) => {
// Find user by FeatureAccess ID to get userId for API call
const user = instanceUsers.find(u => u.id === featureAccessId);
if (user) {
const result = await removeUserFromInstance(selectedMandateId, selectedInstanceId, user.userId);
return result.success;
}
return false;
},
}}
emptyMessage="Keine Benutzer gefunden"
/>
</div>
)}
{/* Add User Modal */}
{showAddModal && (
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Benutzer zur Feature-Instanz hinzufügen</h2>
<button
className={styles.modalClose}
onClick={() => setShowAddModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{availableUsers.length === 0 ? (
<p>Alle Benutzer haben bereits Zugriff auf diese Feature-Instanz.</p>
) : instanceRoles.length === 0 ? (
<p>Diese Feature-Instanz hat keine Rollen. Bitte synchronisieren Sie zuerst die Rollen.</p>
) : (
<FormGeneratorForm
attributes={addUserFields}
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText="Hinzufügen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Roles Modal */}
{editingUser && (
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Rollen bearbeiten: {editingUser.username}</h2>
<button
className={styles.modalClose}
onClick={() => setEditingUser(null)}
>
</button>
</div>
<div className={styles.modalContent}>
<FormGeneratorForm
attributes={editRolesFields}
data={{ roleIds: editingUser.roleIds }}
mode="edit"
onSubmit={handleEditRoles}
onCancel={() => setEditingUser(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
</div>
</div>
</div>
)}
</div>
);
};
export default AdminFeatureInstanceUsersPage;