ui-nyla/src/pages/admin/AdminUsersPage.tsx

364 lines
12 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.

// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* AdminUsersPage
*
* Admin page for managing Users using FormGeneratorTable.
*/
import React, { useState, useMemo, useCallback, useRef } 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, FaDesktop } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import styles from './Admin.module.css';
import { getUserDataCache } from '../../utils/userCache';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
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);
const _lastTableParams = useRef<any>(undefined);
const _tableRefetch = useCallback(async (paginationParams?: any) => {
if (paginationParams) _lastTableParams.current = paginationParams;
else paginationParams = _lastTableParams.current;
return refetch(paginationParams);
}, [refetch]);
// Generate columns from attributes; types from backend via resolveColumnTypes
const columns = useMemo(() => {
const raw = (attributes || []).map(attr => ({
key: attr.name,
label: attr.label || attr.name,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
displayField: (attr as any).displayField,
}));
return resolveColumnTypes(raw, attributes || []);
}, [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);
_tableRefetch();
}
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<User>) => {
if (!editingUser) return;
const result = await updateUser(editingUser.id, data);
if (result.success) {
setEditingUser(null);
_tableRefetch();
}
};
// Handle delete (confirmation handled by DeleteActionButton)
const handleDeleteUser = async (user: User) => {
const success = await deleteUser(user.id);
if (success) {
_tableRefetch();
}
};
// 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 (
<StackLayout variant="table">
<StackLayout.Body>
<Panel variant="card" title={t('Fehler')} id="users-error">
<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={() => _tableRefetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<>
<StackLayout variant="table">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Benutzer')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie alle Benutzer im')}</p>
</div>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar" title={t('Aktionen')} id="users-toolbar">
<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
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/sessions')}
>
<FaDesktop /> {t('Sessions')}
</button>
<button
className={styles.secondaryButton}
onClick={() => _tableRefetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neuer Benutzer')}
</button>
)}
</div>
</Panel>
<Panel variant="table" title={t('Benutzer')} id="users-table">
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint="/api/users/"
filterScopeKey="admin"
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),
}] : []),
{
id: 'viewSessions',
icon: <FaDesktop />,
onClick: (row: User) => navigate(`/admin/sessions?userId=${row.id}`),
title: t('Sessions anzeigen'),
},
]}
onDelete={handleDeleteUser}
hookData={{
refetch: _tableRefetch,
permissions,
pagination,
handleDelete: deleteUser,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage={t('Keine Benutzer gefunden')}
/>
</Panel>
</StackLayout.Body>
</StackLayout>
{/* 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>
)}
</>
);
};
export default AdminUsersPage;