Files
IT-Nexus/frontend/src/pages/UsersPage.jsx

644 lines
36 KiB
JavaScript
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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import userService from '../services/userService';
import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify';
const UsersPage = () => {
const { isSuperAdmin } = useAuth();
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [showImportModal, setShowImportModal] = useState(false);
const [azureGroups, setAzureGroups] = useState([]);
const [azureGroupsLoading, setAzureGroupsLoading] = useState(false);
const [importGroupId, setImportGroupId] = useState('');
const [importRoleId, setImportRoleId] = useState('');
const [importing, setImporting] = useState(false);
const [groupSearch, setGroupSearch] = useState('');
const [search, setSearch] = useState('');
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
role_id: '',
first_name: '',
last_name: '',
is_active: true,
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [usersData, rolesData] = await Promise.all([
userService.getAll(),
userService.getRoles(),
]);
setUsers(usersData);
setRoles(rolesData);
} catch (error) {
toast.error('Fehler beim Laden der Daten');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingUser(null);
setFormData({
username: '',
email: '',
password: '',
role_id: roles[0]?.id || '',
first_name: '',
last_name: '',
is_active: true,
});
setShowModal(true);
};
const handleEdit = (user) => {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
role_id: user.role_id,
first_name: user.first_name || '',
last_name: user.last_name || '',
is_active: user.is_active,
});
setShowModal(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const submitData = { ...formData };
// Remove password if empty (for edit)
if (editingUser && !submitData.password) {
delete submitData.password;
}
if (editingUser) {
await userService.update(editingUser.id, submitData);
toast.success('Benutzer erfolgreich aktualisiert');
} else {
await userService.create(submitData);
toast.success('Benutzer erfolgreich erstellt');
}
setShowModal(false);
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Speichern');
}
};
const handleDelete = async (id) => {
if (!window.confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
return;
}
try {
await userService.delete(id);
toast.success('Benutzer erfolgreich gelöscht');
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Löschen');
}
};
const handleToggleStatus = async (user) => {
try {
await userService.toggleStatus(user.id, !user.is_active);
toast.success('Status erfolgreich geändert');
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Ändern des Status');
}
};
const handleOpenImportModal = async () => {
setImportGroupId('');
setImportRoleId(roles[0]?.id || '');
setGroupSearch('');
setShowImportModal(true);
setAzureGroupsLoading(true);
try {
const groups = await userService.getAzureGroups();
setAzureGroups(groups);
} catch (error) {
toast.error(error.message || 'Fehler beim Laden der Azure-Gruppen');
setShowImportModal(false);
} finally {
setAzureGroupsLoading(false);
}
};
const handleImport = async () => {
if (!importGroupId || !importRoleId) return;
setImporting(true);
try {
const result = await userService.importFromAzure(importGroupId, importRoleId);
toast.success(
`Import abgeschlossen: ${result.imported} importiert, ${result.skipped} übersprungen` +
(result.errors.length > 0 ? `, ${result.errors.length} Fehler` : '')
);
setShowImportModal(false);
if (result.imported > 0) loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Importieren');
} finally {
setImporting(false);
}
};
const handleRoleChange = async (userId, roleId) => {
try {
await userService.assignRole(userId, roleId);
toast.success('Rolle erfolgreich zugewiesen');
loadData();
} catch (error) {
toast.error(error.message || 'Fehler beim Zuweisen der Rolle');
}
};
if (loading) {
return (
<div className="main-content">
<LoadingSpinner />
</div>
);
}
return (
<div className="main-content">
<div className="container">
<div className="flex justify-between items-center mb-3">
<h1>Benutzerverwaltung</h1>
{isSuperAdmin() && (
<div className="flex" style={{ gap: '0.5rem' }}>
<button onClick={handleOpenImportModal} className="btn btn-secondary">
Aus Azure importieren
</button>
<button onClick={handleCreate} className="btn btn-primary">
+ Neuer Benutzer
</button>
</div>
)}
</div>
{/* Search */}
<div style={{ marginBottom: 12 }}>
<input
className="form-input"
type="text"
placeholder="🔍 Suche nach Name, Benutzername, E-Mail oder Rolle…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{ maxWidth: 420 }}
/>
</div>
{/* Table */}
<div className="card">
<table className="table">
<thead>
<tr>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Name</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzter Login</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{(() => {
const q = search.toLowerCase();
const filtered = q
? users.filter(u =>
u.username?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q) ||
u.first_name?.toLowerCase().includes(q) ||
u.last_name?.toLowerCase().includes(q) ||
u.role_name?.toLowerCase().includes(q)
)
: users;
if (filtered.length === 0) return (
<tr><td colSpan="7" className="text-center">Keine Benutzer gefunden</td></tr>
);
return filtered.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.email}</td>
<td>
{user.first_name || user.last_name
? `${user.first_name || ''} ${
user.last_name || ''
}`.trim()
: '-'}
</td>
<td>
<span className="role-badge">{user.role_name}</span>
</td>
<td>
<span
className={`status-badge ${
user.is_active
? 'status-aktiv'
: 'status-inaktiv'
}`}
>
{user.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td>
{user.last_login
? new Date(user.last_login).toLocaleString('de-DE')
: 'Nie'}
</td>
<td>
{isSuperAdmin() && (
<div className="table-actions">
<button
onClick={() => handleEdit(user)}
className="btn btn-primary btn-small"
>
Bearbeiten
</button>
<button
onClick={() => handleToggleStatus(user)}
className="btn btn-secondary btn-small"
>
{user.is_active
? 'Deaktivieren'
: 'Aktivieren'}
</button>
<button
onClick={() => handleDelete(user.id)}
className="btn btn-danger btn-small"
>
Löschen
</button>
</div>
)}
</td>
</tr>
));
})()}
</tbody>
</table>
</div>
{/* Azure Import Modal */}
{showImportModal && (
<div className="modal-overlay" onClick={() => setShowImportModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Benutzer aus Azure AD importieren</h2>
<button className="modal-close" onClick={() => setShowImportModal(false)}>×</button>
</div>
<div className="modal-body">
{azureGroupsLoading ? (
<p style={{ textAlign: 'center', padding: '1rem' }}>Gruppen werden geladen...</p>
) : (
<>
<div className="form-group">
<label className="form-label">Azure AD Gruppe*</label>
<input
type="text"
className="form-input"
placeholder="Gruppe suchen..."
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
style={{ marginBottom: '0.4rem' }}
/>
<div style={{
border: '1px solid var(--border-color)',
borderRadius: '6px',
maxHeight: '220px',
overflowY: 'auto',
background: 'var(--bg-secondary)',
}}>
{azureGroups
.filter(g => g.displayName.toLowerCase().includes(groupSearch.toLowerCase()))
.map((g) => (
<div
key={g.id}
onClick={() => setImportGroupId(g.id)}
style={{
padding: '8px 12px',
cursor: 'pointer',
borderBottom: '1px solid var(--border-color)',
background: importGroupId === g.id ? 'var(--primary)' : 'transparent',
color: importGroupId === g.id ? '#fff' : 'var(--text-primary)',
}}
>
<div style={{ fontWeight: 500, fontSize: '14px' }}>{g.displayName}</div>
{g.description && (
<div style={{
fontSize: '12px',
opacity: 0.7,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}>
{g.description}
</div>
)}
</div>
))
}
{azureGroups.filter(g => g.displayName.toLowerCase().includes(groupSearch.toLowerCase())).length === 0 && (
<div style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: '13px' }}>
Keine Gruppen gefunden
</div>
)}
</div>
{importGroupId && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>
Ausgewählt: <strong>{azureGroups.find(g => g.id === importGroupId)?.displayName}</strong>
</div>
)}
</div>
<div className="form-group">
<label className="form-label">Rolle für importierte Benutzer *</label>
{(() => {
const ROLE_META = {
super_admin: { icon: '👑', color: '#ef4444', bg: '#fef2f2', label: 'Super Admin' },
admin: { icon: '🛡️', color: '#f97316', bg: '#fff7ed', label: 'Admin' },
support: { icon: '🎧', color: '#3b82f6', bg: '#eff6ff', label: 'IT-Support' },
bearbeiter: { icon: '🔧', color: '#8b5cf6', bg: '#f5f3ff', label: 'Bearbeiter' },
benutzer: { icon: '👤', color: '#6b7280', bg: '#f9fafb', label: 'Benutzer' },
hr_personal: { icon: '🧑‍💼', color: '#10b981', bg: '#f0fdf4', label: 'HR / Personal' },
buchhaltung: { icon: '💶', color: '#a78bfa', bg: '#faf5ff', label: 'Buchhaltung' },
};
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{roles.map(role => {
const meta = ROLE_META[role.name] || { icon: '🔵', color: '#6b7280', bg: '#f9fafb', label: role.name };
const selected = String(importRoleId) === String(role.id);
return (
<button
key={role.id}
type="button"
onClick={() => setImportRoleId(role.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', borderRadius: 8, cursor: 'pointer',
border: `2px solid ${selected ? meta.color : 'var(--border-color)'}`,
background: selected ? meta.bg : 'var(--bg-secondary)',
textAlign: 'left', transition: 'all 0.15s',
boxShadow: selected ? `0 0 0 3px ${meta.color}22` : 'none',
}}
>
<div style={{
width: 32, height: 32, borderRadius: '50%', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontSize: 16,
background: selected ? meta.color + '22' : 'var(--bg-tertiary)',
flexShrink: 0,
}}>{meta.icon}</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: selected ? meta.color : 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{meta.label}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.3, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{role.description}
</div>
</div>
{selected && (
<div style={{ marginLeft: 'auto', color: meta.color, fontSize: 16, flexShrink: 0 }}></div>
)}
</button>
);
})}
</div>
);
})()}
</div>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '0.5rem' }}>
Bereits vorhandene Benutzer (gleiche E-Mail oder Azure ID) werden übersprungen.
</p>
</>
)}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowImportModal(false)}>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={handleImport}
disabled={importing || azureGroupsLoading || !importGroupId || !importRoleId}
>
{importing ? 'Importiere...' : 'Importieren'}
</button>
</div>
</div>
</div>
)}
{/* Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">
{editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}
</h2>
<button
className="modal-close"
onClick={() => setShowModal(false)}
>
×
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Benutzername*</label>
<input
type="text"
className="form-input"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label className="form-label">E-Mail*</label>
<input
type="email"
className="form-input"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
/>
</div>
<div className="form-group">
<label className="form-label">
Passwort{editingUser ? '' : '*'}
</label>
<input
type="password"
className="form-input"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
placeholder={
editingUser
? 'Leer lassen, um nicht zu ändern'
: ''
}
/>
</div>
<div className="form-group">
<label className="form-label">Vorname</label>
<input
type="text"
className="form-input"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
/>
</div>
<div className="form-group">
<label className="form-label">Nachname</label>
<input
type="text"
className="form-input"
value={formData.last_name}
onChange={(e) =>
setFormData({ ...formData, last_name: e.target.value })
}
/>
</div>
<div className="form-group">
<label className="form-label">Rolle *</label>
{(() => {
const ROLE_META = {
super_admin: { icon: '👑', color: '#ef4444', bg: '#fef2f2', label: 'Super Admin' },
admin: { icon: '🛡️', color: '#f97316', bg: '#fff7ed', label: 'Admin' },
support: { icon: '🎧', color: '#3b82f6', bg: '#eff6ff', label: 'IT-Support' },
bearbeiter: { icon: '🔧', color: '#8b5cf6', bg: '#f5f3ff', label: 'Bearbeiter' },
benutzer: { icon: '👤', color: '#6b7280', bg: '#f9fafb', label: 'Benutzer' },
hr_personal: { icon: '🧑‍💼', color: '#10b981', bg: '#f0fdf4', label: 'HR / Personal' },
buchhaltung: { icon: '💶', color: '#a78bfa', bg: '#faf5ff', label: 'Buchhaltung' },
};
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{roles.map(role => {
const meta = ROLE_META[role.name] || { icon: '🔵', color: '#6b7280', bg: '#f9fafb', label: role.name };
const selected = formData.role_id === role.id;
return (
<button
key={role.id}
type="button"
onClick={() => setFormData({ ...formData, role_id: role.id })}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', borderRadius: 8, cursor: 'pointer',
border: `2px solid ${selected ? meta.color : 'var(--border-color)'}`,
background: selected ? meta.bg : 'var(--bg-secondary)',
textAlign: 'left', transition: 'all 0.15s',
boxShadow: selected ? `0 0 0 3px ${meta.color}22` : 'none',
}}
>
<div style={{
width: 32, height: 32, borderRadius: '50%', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontSize: 16,
background: selected ? meta.color + '22' : 'var(--bg-tertiary)',
flexShrink: 0,
}}>{meta.icon}</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: selected ? meta.color : 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{meta.label}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.3, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{role.description}
</div>
</div>
{selected && (
<div style={{ marginLeft: 'auto', color: meta.color, fontSize: 16, flexShrink: 0 }}></div>
)}
</button>
);
})}
</div>
);
})()}
{!formData.role_id && (
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>Bitte eine Rolle auswählen</div>
)}
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
style={{ marginRight: '0.5rem' }}
/>
Benutzer ist aktiv
</label>
</div>
<div className="card-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Speichern
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
);
};
export default UsersPage;