Initial commit
Dieser Commit ist enthalten in:
658
admin-panel/src/views/UserManagement.tsx
Normale Datei
658
admin-panel/src/views/UserManagement.tsx
Normale Datei
@ -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 E‑Mail.</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
className="input-field w-80"
|
||||
placeholder="E‑Mail, 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>
|
||||
)
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren