Initial commit: IT Nexus Web-App

This commit is contained in:
2026-06-01 20:49:07 +02:00
commit 8023765e6c
387 changed files with 106900 additions and 0 deletions

View File

@@ -0,0 +1,643 @@
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;