frontend_nyla/src/pages/admin/AdminUsersPage.tsx

331 lines
10 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.

/**
* AdminUsersPage
*
* Admin page for managing Users using FormGeneratorTable.
*/
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOrgUsers, useUserOperations } from '../../hooks/useUsers';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
import styles from './Admin.module.css';
import { getUserDataCache } from '../../utils/userCache';
import { useLanguage } from '../../providers/language/LanguageContext';
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
interface User {
id: string;
username: string;
email: string;
fullName: string;
enabled: boolean;
isSysAdmin?: boolean;
isPlatformAdmin?: boolean;
[key: string]: any;
}
export const AdminUsersPage: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
// Use two hooks: one for data, one for operations
const {
data: users,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchUserById,
updateOptimistically,
} = useOrgUsers();
const {
handleUserCreate: createUser,
handleUserUpdate: updateUser,
handleUserDelete: deleteUser,
handleSendPasswordLink,
handleInlineUpdate,
sendingPasswordLink: sendingPasswordLinkState,
} = useUserOperations();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
// Generate columns from attributes
const columns = useMemo(() => {
return (attributes || []).map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
}));
}, [attributes]);
// Check permissions
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click
const handleEditClick = async (user: User) => {
const fullUser = await fetchUserById(user.id);
if (fullUser) {
setEditingUser(fullUser as User);
}
};
// Handle create submit
const handleCreateSubmit = async (data: Partial<User>) => {
const result = await createUser(data as Omit<User, 'id'>);
if (result.success) {
setShowCreateModal(false);
refetch(); // Refresh the list
}
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<User>) => {
if (!editingUser) return;
const result = await updateUser(editingUser.id, data);
if (result.success) {
setEditingUser(null);
refetch(); // Refresh the list
}
};
// Handle delete (confirmation handled by DeleteActionButton)
const handleDeleteUser = async (user: User) => {
const success = await deleteUser(user.id);
if (success) {
refetch(); // Refresh the list
}
};
// Handle send password link
const handleSendPassword = async (user: User) => {
await handleSendPasswordLink(user.id);
};
// Privileged-flag gating mirrors the backend rules in routeDataUsers.update_user
// and create_user: only a Platform-Admin may set isSysAdmin / isPlatformAdmin,
// and even then never on themselves (Self-Protection).
const currentUserCache = getUserDataCache();
const callerIsPlatformAdmin = currentUserCache?.isPlatformAdmin === true;
const callerId = currentUserCache?.id;
const _buildFormAttributes = (mode: 'create' | 'edit', targetUserId?: string) => {
const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority'];
const isSelfEdit = mode === 'edit' && targetUserId !== undefined && targetUserId === callerId;
// Caller may flip flags only when PlatformAdmin AND not editing themselves.
const flagsEditable = callerIsPlatformAdmin && !isSelfEdit;
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => {
if (_PRIVILEGED_FLAGS.includes(attr.name as any) && !flagsEditable) {
return { ...attr, editable: false };
}
if (attr.name === 'username') {
return { ...attr, editable: false };
}
return attr;
});
};
const formAttributesCreate = useMemo(
() => _buildFormAttributes('create'),
[attributes, callerIsPlatformAdmin],
);
const formAttributesEdit = useMemo(
() => _buildFormAttributes('edit', editingUser?.id),
[attributes, callerIsPlatformAdmin, callerId, editingUser?.id],
);
if (error) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler beim Laden der Benutzer')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Benutzer')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie alle Benutzer im')}</p>
</div>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/user-access-overview')}
>
<FaUserShield /> {t('Zugriffsübersicht')}
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/invitations')}
>
<FaEnvelopeOpenText /> {t('Einladungen')}
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neuer Benutzer')}
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint="/api/users/"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Bearbeiten'),
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: t('Löschen'),
}] : []),
]}
customActions={canUpdate ? [
{
id: 'sendPasswordLink',
icon: <FaKey />,
onClick: handleSendPassword,
title: t('Passwort-Link senden'),
loading: (row: User) => sendingPasswordLinkState.has(row.id),
}
] : []}
onDelete={handleDeleteUser}
hookData={{
refetch,
permissions,
pagination,
handleDelete: deleteUser,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage={t('Keine Benutzer gefunden')}
/>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neuer Benutzer')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributesCreate.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributesCreate}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText={t('Erstellen')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingUser && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer bearbeiten')}</h2>
<button
className={styles.modalClose}
onClick={() => setEditingUser(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributesEdit.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributesEdit}
data={editingUser}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingUser(null)}
submitButtonText={t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminUsersPage;