644 lines
36 KiB
JavaScript
644 lines
36 KiB
JavaScript
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;
|