629 lines
22 KiB
TypeScript
629 lines
22 KiB
TypeScript
/**
|
||
* 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, FaBuilding, FaCube } from 'react-icons/fa';
|
||
import { useToast } from '../../contexts/ToastContext';
|
||
import { useFeatureStore } from '../../stores/featureStore';
|
||
import api from '../../api';
|
||
import styles from './Admin.module.css';
|
||
|
||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||
|
||
export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||
const { t } = useLanguage();
|
||
|
||
const {
|
||
features,
|
||
instances,
|
||
loading,
|
||
error,
|
||
fetchFeatures,
|
||
fetchInstanceUsers,
|
||
addUserToInstance,
|
||
removeUserFromInstance,
|
||
updateInstanceUserRoles,
|
||
fetchInstanceRoles,
|
||
} = useFeatureAccess();
|
||
|
||
const { fetchMandates } = useUserMandates();
|
||
const { showSuccess, showError } = useToast();
|
||
const { loadFeatures } = useFeatureStore();
|
||
|
||
// 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: mandateDisplayLabel(mandate),
|
||
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);
|
||
if (allOptions.length > 0 && !selectedCombinedKey) {
|
||
setSelectedCombinedKey(allOptions[0].combinedKey);
|
||
}
|
||
};
|
||
|
||
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: t('Benutzername'),
|
||
type: 'text' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
searchable: true,
|
||
width: 150,
|
||
},
|
||
{
|
||
key: 'email',
|
||
label: t('E-Mail'),
|
||
type: 'text' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
searchable: true,
|
||
width: 200,
|
||
},
|
||
{
|
||
key: 'fullName',
|
||
label: t('Vollständiger Name'),
|
||
type: 'text' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
searchable: true,
|
||
width: 180,
|
||
},
|
||
{
|
||
key: 'roleLabels',
|
||
label: t('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: t('Aktiv'),
|
||
type: 'boolean' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
searchable: false,
|
||
width: 80,
|
||
},
|
||
], [t]);
|
||
|
||
// 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: t('Benutzer'),
|
||
type: 'enum' as const,
|
||
required: true,
|
||
options: userOptions,
|
||
},
|
||
{
|
||
name: 'roleIds',
|
||
label: t('Rollen'),
|
||
type: 'multiselect' as const,
|
||
required: true,
|
||
options: roleOptions,
|
||
}
|
||
];
|
||
}, [userOptions, roleOptions, t]);
|
||
|
||
// Form attributes for editing user roles and active flag
|
||
const editRolesFields: AttributeDefinition[] = useMemo(() => {
|
||
return [
|
||
{
|
||
name: 'roleIds',
|
||
label: t('Rollen'),
|
||
type: 'multiselect' as const,
|
||
required: true,
|
||
options: roleOptions,
|
||
},
|
||
{
|
||
name: 'enabled',
|
||
label: t('Aktiv'),
|
||
type: 'checkbox' as const,
|
||
required: false,
|
||
},
|
||
];
|
||
}, [roleOptions, t]);
|
||
|
||
// 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();
|
||
loadFeatures(); // Refresh global navigation cache
|
||
showSuccess(t('Benutzer hinzugefügt'), t('Der Benutzer wurde erfolgreich zur Feature-Instanz hinzugefügt.'));
|
||
} else {
|
||
showError(t('Fehler'), result.error || t('Fehler beim Hinzufügen des Benutzers'));
|
||
}
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Handle edit roles and active submit
|
||
const handleEditRoles = async (data: { roleIds: string[]; enabled?: boolean }) => {
|
||
if (!selectedMandateId || !selectedInstanceId || !editingUser) return;
|
||
setIsSubmitting(true);
|
||
try {
|
||
const result = await updateInstanceUserRoles(
|
||
selectedMandateId,
|
||
selectedInstanceId,
|
||
editingUser.userId,
|
||
{ roleIds: data.roleIds, enabled: data.enabled }
|
||
);
|
||
if (result.success) {
|
||
setEditingUser(null);
|
||
refreshUsers();
|
||
loadFeatures(); // Refresh global navigation cache
|
||
showSuccess(t('Eintrag aktualisiert'), t('Rollen und Aktiv-Status wurden erfolgreich aktualisiert.'));
|
||
} else {
|
||
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren'));
|
||
}
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Handle remove user (confirmation handled by DeleteActionButton)
|
||
const handleRemoveUser = async (user: FeatureAccessUser) => {
|
||
if (!selectedMandateId || !selectedInstanceId) return;
|
||
const result = await removeUserFromInstance(selectedMandateId, selectedInstanceId, user.userId);
|
||
if (result.success) {
|
||
refreshUsers();
|
||
loadFeatures(); // Refresh global navigation cache
|
||
showSuccess(t('Benutzer entfernt'), t('"{name}" wurde aus der Feature-Instanz entfernt.', { name: user.username }));
|
||
} else {
|
||
showError(t('Fehler'), result.error || t('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);
|
||
return feature ? (feature.label || code) : 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} ${styles.adminPageFill}`}>
|
||
<div className={styles.errorContainer}>
|
||
<span className={styles.errorIcon}>⚠️</span>
|
||
<p className={styles.errorMessage}>
|
||
{t('Fehler')}: {error}
|
||
</p>
|
||
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
|
||
<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-Instanz-Benutzer')}</h1>
|
||
<p className={styles.pageSubtitle}>{t('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 }} />
|
||
{t('Mandant / Feature-Instanz')}:
|
||
</label>
|
||
<select
|
||
className={styles.filterSelect}
|
||
value={selectedCombinedKey}
|
||
onChange={(e) => setSelectedCombinedKey(e.target.value)}
|
||
disabled={loading || combinedOptions.length === 0}
|
||
>
|
||
<option value="">{t('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' : ''} /> {t('Aktualisieren')}
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => setShowAddModal(true)}
|
||
disabled={availableUsers.length === 0 || instanceRoles.length === 0}
|
||
>
|
||
<FaPlus /> {t('Benutzer hinzufügen')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info box when instance is selected */}
|
||
{selectedOption && (
|
||
<div className={styles.infoBox}>
|
||
<FaBuilding style={{ marginRight: 8 }} />
|
||
<span>
|
||
{t('Mandant')}: <strong>{selectedOption.mandateName}</strong>
|
||
</span>
|
||
<span style={{ margin: '0 16px', color: 'var(--color-border)' }}>|</span>
|
||
<FaCube style={{ marginRight: 8 }} />
|
||
<span>
|
||
{t('Instanz')}: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode})
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Roles info box */}
|
||
{selectedInstance && instanceRoles.length > 0 && (
|
||
<div className={styles.infoBox}>
|
||
<span>{t('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.infoBox} style={{ borderColor: 'var(--warning-color, #d69e2e)', backgroundColor: 'var(--warning-bg, rgba(214, 158, 46, 0.12))' }}>
|
||
<span>⚠️ </span>
|
||
<span>{t('Diese Instanz hat noch keine')}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Content */}
|
||
{!selectedCombinedKey ? (
|
||
<div className={styles.emptyState}>
|
||
<FaCube className={styles.emptyIcon} />
|
||
<h3 className={styles.emptyTitle}>{t('Keine Feature-Instanz ausgewählt')}</h3>
|
||
<p className={styles.emptyDescription}>
|
||
{combinedOptions.length === 0
|
||
? t('Es gibt noch keine Feature-Instanzen')
|
||
: t('Wählen Sie eine Feature-Instanz aus')}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className={styles.tableContainer}>
|
||
<FormGeneratorTable
|
||
data={instanceUsers}
|
||
columns={columns}
|
||
apiEndpoint={selectedInstanceId ? `/api/features/instances/${selectedInstanceId}/users` : undefined}
|
||
loading={usersLoading}
|
||
pagination={true}
|
||
pageSize={25}
|
||
searchable={true}
|
||
filterable={true}
|
||
sortable={true}
|
||
selectable={true}
|
||
actionButtons={[
|
||
{
|
||
type: 'edit' as const,
|
||
onAction: handleEditClick,
|
||
title: t('Rollen bearbeiten'),
|
||
},
|
||
{
|
||
type: 'delete' as const,
|
||
title: t('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={t('Keine Benutzer gefunden')}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Add User Modal */}
|
||
{showAddModal && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modal}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>{t('Benutzer zur Feature-Instanz hinzufügen')}</h2>
|
||
<button
|
||
className={styles.modalClose}
|
||
onClick={() => setShowAddModal(false)}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className={styles.modalContent}>
|
||
{availableUsers.length === 0 ? (
|
||
<p>{t('Alle Benutzer haben bereits Zugriff')}</p>
|
||
) : instanceRoles.length === 0 ? (
|
||
<p>{t('Diese Feature-Instanz hat keine Rollen')}</p>
|
||
) : (
|
||
<FormGeneratorForm
|
||
attributes={addUserFields}
|
||
mode="create"
|
||
onSubmit={handleAddUser}
|
||
onCancel={() => setShowAddModal(false)}
|
||
submitButtonText={t('Hinzufügen')}
|
||
cancelButtonText={t('Abbrechen')}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit Roles Modal */}
|
||
{editingUser && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modal}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>
|
||
{t('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, enabled: editingUser.enabled }}
|
||
mode="edit"
|
||
onSubmit={handleEditRoles}
|
||
onCancel={() => setEditingUser(null)}
|
||
submitButtonText={t('Speichern')}
|
||
cancelButtonText={t('Abbrechen')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminFeatureInstanceUsersPage;
|