Dieser Commit ist enthalten in:
Claude Project Manager
2025-09-20 21:31:04 +02:00
Commit 6b9b6d4f20
1821 geänderte Dateien mit 348527 neuen und 0 gelöschten Zeilen

Datei anzeigen

@ -0,0 +1,658 @@
import { useState, useEffect, DragEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../services/api'
import { User, UserRole } from '@skillmate/shared'
import { TrashIcon, ShieldIcon, KeyIcon } from '../components/icons'
import { useAuthStore } from '../stores/authStore'
interface UserWithEmployee extends User {
employeeName?: string
}
export default function UserManagement() {
const navigate = useNavigate()
const { user: currentUser } = useAuthStore()
const [users, setUsers] = useState<UserWithEmployee[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editingUser, setEditingUser] = useState<string | null>(null)
const [editRole, setEditRole] = useState<UserRole>('user')
const [resetPasswordUser, setResetPasswordUser] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState('')
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
setLoading(true)
const [usersResponse, employeesResponse] = await Promise.all([
api.get('/admin/users'),
api.get('/employees')
])
const usersData = usersResponse.data.data || []
const employeesData = employeesResponse.data.data || []
// Match users with employee names
const enrichedUsers = usersData.map((user: User) => {
const employee = employeesData.find((emp: any) => emp.id === user.employeeId)
return {
...user,
employeeName: employee ? `${employee.firstName} ${employee.lastName}` : undefined
}
})
setUsers(enrichedUsers)
setEmployees(employeesData)
} catch (err: any) {
console.error('Failed to fetch users:', err)
setError('Benutzer konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const [employees, setEmployees] = useState<any[]>([])
// Import state
type ImportRow = { firstName: string; lastName: string; email: string; department: string }
const [dragActive, setDragActive] = useState(false)
const [parsedRows, setParsedRows] = useState<ImportRow[]>([])
const [parseError, setParseError] = useState('')
const [isImporting, setIsImporting] = useState(false)
const [importResults, setImportResults] = useState<{
index: number
status: 'created' | 'error'
employeeId?: string
userId?: string
username?: string
email?: string
temporaryPassword?: string
error?: string
}[]>([])
// Store temporary passwords per user to show + email
const [tempPasswords, setTempPasswords] = useState<Record<string, { password: string }>>({})
// removed legacy creation helpers for employees without user accounts
const handleRoleChange = async (userId: string) => {
try {
await api.put(`/admin/users/${userId}/role`, { role: editRole })
await fetchUsers()
setEditingUser(null)
} catch (err: any) {
setError('Rolle konnte nicht geändert werden')
}
}
const handleToggleActive = async (userId: string, isActive: boolean) => {
try {
await api.put(`/admin/users/${userId}/status`, { isActive: !isActive })
await fetchUsers()
} catch (err: any) {
setError('Status konnte nicht geändert werden')
}
}
const handlePasswordReset = async (userId: string) => {
try {
const response = await api.post(`/admin/users/${userId}/reset-password`, {
newPassword: newPassword || undefined
})
const tempPassword = response.data.data?.temporaryPassword
if (tempPassword) {
setTempPasswords(prev => ({ ...prev, [userId]: { password: tempPassword } }))
}
setResetPasswordUser(null)
setNewPassword('')
} catch (err: any) {
setError('Passwort konnte nicht zurückgesetzt werden')
}
}
const sendTempPasswordEmail = async (userId: string, password: string) => {
try {
await api.post(`/admin/users/${userId}/send-temp-password`, { password })
alert('Temporäres Passwort per E-Mail versendet.')
} catch (err: any) {
setError('E-Mail-Versand des temporären Passworts fehlgeschlagen')
}
}
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragActive(true)
}
const onDragLeave = () => setDragActive(false)
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragActive(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0])
}
}
const handleFile = (file: File) => {
setParseError('')
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result || '')
if (file.name.toLowerCase().endsWith('.json')) {
const json = JSON.parse(text)
const rows: ImportRow[] = Array.isArray(json) ? json : [json]
validateAndSetRows(rows)
} else {
// Simple CSV parser (comma or semicolon)
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0)
if (lines.length === 0) throw new Error('Leere Datei')
const header = lines[0].split(/[;,]/).map(h => h.trim().toLowerCase())
const idx = (name: string) => header.indexOf(name)
const rows: ImportRow[] = []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(/[;,]/).map(c => c.trim())
rows.push({
firstName: cols[idx('firstname')] || cols[idx('vorname')] || '',
lastName: cols[idx('lastname')] || cols[idx('nachname')] || '',
email: cols[idx('email')] || '',
department: cols[idx('department')] || cols[idx('abteilung')] || ''
})
}
validateAndSetRows(rows)
}
} catch (err: any) {
setParseError(err.message || 'Datei konnte nicht gelesen werden')
setParsedRows([])
}
}
reader.readAsText(file)
}
const validateAndSetRows = (rows: ImportRow[]) => {
const valid: ImportRow[] = []
for (const r of rows) {
if (!r.firstName || !r.lastName || !r.email || !r.department) continue
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.email)) continue
valid.push({ ...r })
}
setParsedRows(valid)
}
const startImport = async () => {
if (parsedRows.length === 0) return
setIsImporting(true)
setImportResults([])
try {
const results: any[] = []
for (let i = 0; i < parsedRows.length; i++) {
const r = parsedRows[i]
try {
const res = await api.post('/employees', {
firstName: r.firstName,
lastName: r.lastName,
email: r.email,
department: r.department,
createUser: true,
userRole: 'user'
})
const data = res.data?.data || {}
results.push({
index: i,
status: 'created',
employeeId: data.id,
userId: data.userId,
email: r.email,
username: r.email?.split('@')[0],
temporaryPassword: data.temporaryPassword
})
} catch (err: any) {
results.push({ index: i, status: 'error', error: err.response?.data?.error?.message || 'Fehler beim Import' })
}
}
setImportResults(results)
await fetchUsers()
} finally {
setIsImporting(false)
}
}
const handleDeleteUser = async (userId: string) => {
if (!confirm('Möchten Sie diesen Benutzer wirklich löschen?')) return
try {
await api.delete(`/admin/users/${userId}`)
await fetchUsers()
} catch (err: any) {
setError('Benutzer konnte nicht gelöscht werden')
}
}
const getRoleBadgeColor = (role: UserRole) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800'
case 'superuser':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getRoleLabel = (role: UserRole) => {
switch (role) {
case 'admin':
return 'Administrator'
case 'superuser':
return 'Poweruser'
default:
return 'Benutzer'
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-secondary">Lade Benutzer...</div>
</div>
)
}
return (
<div>
<div className="mb-8 flex justify-between items-start">
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">
Benutzerverwaltung
</h1>
<p className="text-body text-secondary">
Verwalten Sie Benutzerkonten, Rollen und Zugriffsrechte
</p>
</div>
<button
onClick={() => navigate('/users/create-employee')}
className="btn-primary"
>
+ Neuen Mitarbeiter anlegen
</button>
</div>
{currentUser?.role === 'admin' && (
<div className="card mb-6 bg-red-50 border border-red-200">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-2">Administrative Aktionen</h3>
<PurgeUsersPanel onDone={fetchUsers} />
</div>
)}
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Benutzer
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Mitarbeiter
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Rolle
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Status
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Letzter Login
</th>
<th className="text-right py-3 px-4 font-poppins font-medium text-secondary">
Aktionen
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-border-light hover:bg-bg-hover">
<td className="py-4 px-4">
<div className="flex items-center">
<div className="w-8 h-8 bg-primary-blue rounded-full flex items-center justify-center text-white text-sm font-medium mr-3">
{user.username === 'admin' ? 'A' : user.username.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-primary">
{user.username === 'admin' ? 'admin' : user.username}
</span>
</div>
</td>
<td className="py-4 px-4">
{user.employeeName ? (
<span className="text-secondary">{user.employeeName}</span>
) : (
<span className="text-tertiary italic">Nicht verknüpft</span>
)}
</td>
<td className="py-4 px-4">
{editingUser === user.id ? (
<div className="flex items-center space-x-2">
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)}
className="input-field py-1 text-sm"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<button
onClick={() => handleRoleChange(user.id)}
className="text-primary-blue hover:text-primary-blue-hover"
>
</button>
<button
onClick={() => setEditingUser(null)}
className="text-error hover:text-red-700"
>
</button>
</div>
) : (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{getRoleLabel(user.role)}
</span>
)}
</td>
<td className="py-4 px-4">
<button
onClick={() => handleToggleActive(user.id, user.isActive)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="py-4 px-4 text-secondary text-sm">
{user.lastLogin ? new Date(user.lastLogin).toLocaleString('de-DE') : 'Nie'}
</td>
<td className="py-4 px-4">
<div className="flex justify-end space-x-2">
<button
onClick={() => {
setEditingUser(user.id)
setEditRole(user.role)
}}
className="p-1 text-secondary hover:text-primary-blue transition-colors"
title="Rolle bearbeiten"
>
<ShieldIcon className="w-5 h-5" />
</button>
{resetPasswordUser === user.id ? (
<div className="flex items-center space-x-2">
<input
type="text"
placeholder="Neues Passwort (leer = zufällig)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="input-field py-1 text-sm w-40"
/>
<button
onClick={() => handlePasswordReset(user.id)}
className="text-primary-blue hover:text-primary-blue-hover"
>
</button>
<button
onClick={() => {
setResetPasswordUser(null)
setNewPassword('')
}}
className="text-error hover:text-red-700"
>
</button>
</div>
) : (
<button
onClick={() => setResetPasswordUser(user.id)}
className="p-1 text-secondary hover:text-warning transition-colors"
title="Passwort zurücksetzen"
>
<KeyIcon className="w-5 h-5" />
</button>
)}
<button
onClick={() => handleDeleteUser(user.id)}
className="p-1 text-secondary hover:text-error transition-colors"
title="Benutzer löschen"
disabled={user.username === 'admin'}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
{tempPasswords[user.id] && (
<div className="mt-2 bg-green-50 border border-green-200 rounded-input p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-green-800">
Temporäres Passwort: <code className="bg-gray-100 px-2 py-0.5 rounded">{tempPasswords[user.id].password}</code>
</div>
<div className="flex gap-2">
<button
className="btn-secondary h-8 px-3"
onClick={() => navigator.clipboard.writeText(tempPasswords[user.id].password)}
>
Kopieren
</button>
<button
className="btn-primary h-8 px-3"
onClick={() => sendTempPasswordEmail(user.id, tempPasswords[user.id].password)}
>
E-Mail senden
</button>
</div>
</div>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && (
<div className="text-center py-8 text-secondary">
Keine Benutzer gefunden
</div>
)}
</div>
</div>
<div className="mt-6 card bg-blue-50 border-blue-200">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-3">
Hinweise zur Benutzerverwaltung
</h3>
<ul className="space-y-2 text-body text-secondary">
<li> <strong>Administrator:</strong> Vollzugriff auf alle Funktionen und Einstellungen</li>
<li> <strong>Poweruser:</strong> Kann Mitarbeiter und Skills verwalten, aber keine Systemeinstellungen ändern</li>
<li> <strong>Benutzer:</strong> Kann nur eigenes Profil bearbeiten und Daten einsehen</li>
<li> Neue Benutzer können über den Import oder die Mitarbeiterverwaltung angelegt werden</li>
<li> Der Admin-Benutzer kann nicht gelöscht werden</li>
</ul>
</div>
<div className="mt-8 card">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-3">
Import neue Nutzer (CSV oder JSON)
</h3>
<div
className={`border-2 border-dashed rounded-input p-6 text-center cursor-pointer ${dragActive ? 'border-primary-blue' : 'border-border-default'}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<p className="text-secondary mb-2">Datei hierher ziehen oder auswählen</p>
<input
type="file"
accept=".csv,.json,application/json,text/csv"
onChange={(e) => e.target.files && e.target.files[0] && handleFile(e.target.files[0])}
className="hidden"
id="user-import-input"
/>
<label htmlFor="user-import-input" className="btn-secondary inline-block">Datei auswählen</label>
{parseError && <div className="text-error text-sm mt-3">{parseError}</div>}
</div>
<div className="mt-4 text-body text-secondary">
<p className="mb-2 font-medium">Eingabekonventionen:</p>
<ul className="list-disc ml-5 space-y-1">
<li>CSV mit Kopfzeile: firstName;lastName;email;department (Komma oder Semikolon)</li>
<li>JSON: Array von Objekten mit Schlüsseln firstName, lastName, email, department</li>
<li>E-Mail muss valide sein; Rolle wird initial immer user</li>
<li>Es wird stets ein temporäres Passwort erzeugt; Anzeige nach Import</li>
</ul>
</div>
{parsedRows.length > 0 && (
<div className="mt-4">
<h4 className="text-body font-semibold text-secondary mb-2">Vorschau ({parsedRows.length} Einträge)</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-2 px-3 text-secondary">Vorname</th>
<th className="text-left py-2 px-3 text-secondary">Nachname</th>
<th className="text-left py-2 px-3 text-secondary">E-Mail</th>
<th className="text-left py-2 px-3 text-secondary">Abteilung</th>
</tr>
</thead>
<tbody>
{parsedRows.map((r, idx) => (
<tr key={idx} className="border-b border-border-light">
<td className="py-2 px-3">{r.firstName}</td>
<td className="py-2 px-3">{r.lastName}</td>
<td className="py-2 px-3">{r.email}</td>
<td className="py-2 px-3">{r.department}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 flex justify-end">
<button className="btn-primary" onClick={startImport} disabled={isImporting}>
{isImporting ? 'Importiere...' : 'Import starten'}
</button>
</div>
</div>
)}
{importResults.length > 0 && (
<div className="mt-6">
<h4 className="text-body font-semibold text-secondary mb-2">Import-Ergebnis</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-2 px-3 text-secondary">Zeile</th>
<th className="text-left py-2 px-3 text-secondary">E-Mail</th>
<th className="text-left py-2 px-3 text-secondary">Status</th>
<th className="text-left py-2 px-3 text-secondary">Temporäres Passwort</th>
<th className="text-right py-2 px-3 text-secondary">Aktionen</th>
</tr>
</thead>
<tbody>
{importResults.map((r, idx) => (
<tr key={idx} className="border-b border-border-light">
<td className="py-2 px-3">{r.index + 1}</td>
<td className="py-2 px-3">{r.email || '—'}</td>
<td className="py-2 px-3">{r.status === 'created' ? 'Erstellt' : `Fehler: ${r.error}`}</td>
<td className="py-2 px-3">
{r.temporaryPassword ? (
<code className="bg-gray-100 px-2 py-1 rounded">{r.temporaryPassword}</code>
) : (
'—'
)}
</td>
<td className="py-2 px-3 text-right">
{r.userId && r.temporaryPassword && (
<div className="flex justify-end gap-2">
<button
className="btn-secondary h-9 px-3"
onClick={() => navigator.clipboard.writeText(r.temporaryPassword!)}
>
Kopieren
</button>
<button
className="btn-primary h-9 px-3"
onClick={() => sendTempPasswordEmail(r.userId!, r.temporaryPassword!)}
>
E-Mail senden
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)
}
function PurgeUsersPanel({ onDone }: { onDone: () => void }) {
const [email, setEmail] = useState('hendrik.gebhardt@polizei.nrw.de')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const runPurge = async () => {
if (!email || !email.includes('@')) {
setMsg('Bitte gültige E-Mail eingeben')
return
}
const ok = confirm(`Achtung: Dies löscht alle Benutzer außer 'admin' und ${email}. Fortfahren?`)
if (!ok) return
try {
setBusy(true)
setMsg('')
const res = await api.post('/admin/users/purge', { email })
const { kept, deleted } = res.data.data || {}
setMsg(`Bereinigung abgeschlossen. Behalten: ${kept}, gelöscht: ${deleted}`)
onDone()
} catch (e: any) {
setMsg('Bereinigung fehlgeschlagen')
} finally {
setBusy(false)
}
}
return (
<div className="space-y-3">
<p className="text-body text-secondary">Nur Administratoren: Löscht alle Benutzer und behält nur 'admin' und die angegebene EMail.</p>
<div className="flex items-center gap-3">
<input
className="input-field w-80"
placeholder="EMail, die erhalten bleiben soll"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button className="btn-danger" onClick={runPurge} disabled={busy}>
Bereinigen
</button>
</div>
{msg && <div className="text-secondary">{msg}</div>}
</div>
)
}