Files
Lageplan/src/components/admin/users-tab.tsx
Pepe Ziberi f58f1b31bf
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
feat(auth): WebAuthn (YubiKey/Passkeys) + Admin-2FA-Reset-Button (v1.8.1)
- WebAuthn/FIDO2 via @simplewebauthn v13: Registrierung (Session) + Login-Faktor (öffentlich),
  zustandslose Challenge-Token (JWT), Zähler/Klon-Schutz, Credentials in webauthn_credentials
  - Routen: /api/auth/mfa/webauthn/register/{options,verify}, DELETE /webauthn/[id],
    /api/auth/mfa-login/webauthn/{options,verify}
- Settings: Sicherheitsschlüssel/Passkey hinzufügen + Liste + entfernen
- Login: "Mit Sicherheitsschlüssel/Passkey anmelden" (neben TOTP)
- Admin: 2FA-Zurücksetzen-Button in der Benutzerliste (nutzt bestehende mfa-reset-API)
- Sicherheitsseite + TOM-Doku: 2FA als vorhanden dokumentiert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:15:03 +02:00

349 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { useAuth } from '@/components/providers/auth-provider'
import { UserPlus, Pencil, Trash2, KeyRound, ShieldCheck, CheckCircle, Ban, Search, Loader2 } from 'lucide-react'
import { formatDateTime } from '@/lib/utils'
import { ROLES, type UserRecord, type TenantRecord } from './admin-constants'
export function UsersTab() {
const { toast } = useToast()
const { user } = useAuth()
const isServerAdmin = user?.role === 'SERVER_ADMIN'
const [users, setUsers] = useState<UserRecord[]>([])
const [tenants, setTenants] = useState<TenantRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const [search, setSearch] = useState('')
// User Dialog
const [isUserDialogOpen, setIsUserDialogOpen] = useState(false)
const [editingUser, setEditingUser] = useState<UserRecord | null>(null)
const [userName, setUserName] = useState('')
const [userEmail, setUserEmail] = useState('')
const [userPassword, setUserPassword] = useState('')
const [userPasswordConfirm, setUserPasswordConfirm] = useState('')
const [userRole, setUserRole] = useState<string>('OPERATOR')
const [userTenantId, setUserTenantId] = useState<string>('')
const fetchUsers = useCallback(async () => {
setIsLoading(true)
try {
const res = await fetch('/api/admin/users')
if (res.ok) setUsers((await res.json()).users || [])
} catch (e) {
console.error('Error fetching users:', e)
} finally {
setIsLoading(false)
}
}, [])
const fetchTenants = useCallback(async () => {
if (!isServerAdmin) return
try {
const res = await fetch('/api/admin/tenants')
if (res.ok) setTenants((await res.json()).tenants || [])
} catch {}
}, [isServerAdmin])
useEffect(() => { fetchUsers(); fetchTenants() }, [fetchUsers, fetchTenants])
const openNewUser = () => {
setEditingUser(null); setUserName(''); setUserEmail(''); setUserPassword('')
setUserPasswordConfirm(''); setUserRole('OPERATOR'); setUserTenantId('')
setIsUserDialogOpen(true)
}
const openEditUser = (u: UserRecord) => {
setEditingUser(u); setUserName(u.name); setUserEmail(u.email); setUserPassword('')
setUserPasswordConfirm(''); setUserRole(u.role)
setUserTenantId(u.memberships?.[0]?.tenant?.id || '')
setIsUserDialogOpen(true)
}
const handleSaveUser = async () => {
try {
if (userPassword && userPassword !== userPasswordConfirm) {
toast({ title: 'Fehler', description: 'Passwörter stimmen nicht überein.', variant: 'destructive' })
return
}
const url = editingUser ? `/api/admin/users/${editingUser.id}` : '/api/admin/users'
const method = editingUser ? 'PATCH' : 'POST'
const body: any = { name: userName, email: userEmail, role: userRole }
if (userPassword) body.password = userPassword
// Mandanten-Zuordnung nur beim Anlegen durch den Super-Admin (verhindert verwaiste User)
if (!editingUser && isServerAdmin && userRole !== 'SERVER_ADMIN' && userTenantId) {
body.tenantId = userTenantId
}
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
if (res.ok) {
toast({ title: editingUser ? 'Benutzer aktualisiert' : 'Benutzer erstellt' })
setIsUserDialogOpen(false)
fetchUsers()
} else {
const err = await res.json(); throw new Error(err.error)
}
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
}
}
const handleDeleteUser = async (id: string) => {
if (!confirm('Benutzer wirklich löschen?')) return
try {
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
if (res.ok) { toast({ title: 'Benutzer gelöscht' }); fetchUsers() }
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}
const handleResetUserMfa = async (targetUser: UserRecord) => {
if (!confirm(`Zwei-Faktor (2FA) von "${targetUser.name}" zurücksetzen? Der Benutzer meldet sich danach nur mit Passwort an, bis er 2FA neu einrichtet.`)) return
try {
const res = await fetch(`/api/admin/users/${targetUser.id}/mfa-reset`, { method: 'POST' })
if (res.ok) { toast({ title: '2FA zurückgesetzt' }) }
else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) }
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}
const handleToggleUserVerified = async (targetUser: UserRecord) => {
try {
const newVal = !targetUser.emailVerified
const res = await fetch(`/api/admin/users/${targetUser.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emailVerified: newVal }),
})
if (res.ok) { toast({ title: newVal ? 'Benutzer freigeschaltet' : 'Verifizierung entfernt' }); fetchUsers() }
else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) }
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}
const handleToggleUserActive = async (targetUser: UserRecord) => {
const willDeactivate = targetUser.isActive !== false
if (willDeactivate && !confirm(`"${targetUser.name}" sperren? Der Benutzer kann sich dann nicht mehr anmelden.`)) return
try {
const res = await fetch(`/api/admin/users/${targetUser.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !willDeactivate }),
})
if (res.ok) { toast({ title: willDeactivate ? 'Benutzer gesperrt' : 'Benutzer entsperrt' }); fetchUsers() }
else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) }
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}
const handleResetUserPassword = async (targetUser: UserRecord) => {
try {
const res = await fetch(`/api/admin/users/${targetUser.id}/reset-password`, { method: 'POST' })
const data = await res.json()
if (res.ok && data.success) {
if (data.emailSent) {
toast({ title: 'Reset-Link gesendet', description: data.message })
} else if (data.resetUrl) {
await navigator.clipboard.writeText(data.resetUrl).catch(() => {})
toast({ title: 'Reset-Link generiert', description: 'Link wurde in die Zwischenablage kopiert. Kein SMTP konfiguriert.' })
}
} else {
toast({ title: 'Fehler', description: data.error, variant: 'destructive' })
}
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}
const q = search.trim().toLowerCase()
const filteredUsers = q
? users.filter(u =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.memberships || []).some(m => m.tenant?.name?.toLowerCase().includes(q)))
: users
const lockedCount = users.filter(u => u.isActive === false).length
return (
<div className="space-y-4">
<div className="flex justify-between items-center gap-3 flex-wrap">
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-muted-foreground" />
<Input value={search} onChange={e => setSearch(e.target.value)} placeholder="Suche Name, E-Mail, Mandant…" className="pl-8 w-[260px]" />
</div>
<p className="text-sm text-muted-foreground">
{filteredUsers.length}{q ? ` / ${users.length}` : ''} Benutzer
{lockedCount > 0 && ` · ${lockedCount} gesperrt`}
</p>
</div>
<Button onClick={openNewUser}>
<UserPlus className="w-4 h-4 mr-2" />
Neuer Benutzer
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : (
<div className="border rounded-lg overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Name</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">E-Mail</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Rolle</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Mandant</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Letzte Anmeldung</th>
<th className="text-left text-xs font-medium text-muted-foreground px-4 py-3">Status</th>
<th className="text-right text-xs font-medium text-muted-foreground px-4 py-3">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{filteredUsers.map(u => {
const inactive = u.isActive === false
const tenantNames = (u.memberships || []).map(m => m.tenant?.name).filter(Boolean)
return (
<tr key={u.id} className={`hover:bg-muted/30 transition-colors ${inactive ? 'opacity-50' : ''}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white ${
u.role === 'SERVER_ADMIN' ? 'bg-red-500' : u.role === 'TENANT_ADMIN' ? 'bg-orange-500' : u.role === 'OPERATOR' ? 'bg-blue-500' : 'bg-gray-400'
}`}>
{u.name.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-sm">{u.name}</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{u.email}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
u.role === 'SERVER_ADMIN' ? 'bg-red-100 text-red-700' :
u.role === 'TENANT_ADMIN' ? 'bg-orange-100 text-orange-700' :
u.role === 'OPERATOR' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>
{ROLES.find(r => r.value === u.role)?.label || u.role}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{tenantNames.length > 0 ? tenantNames.join(', ') : <span className="text-muted-foreground/50"></span>}
</td>
<td className="px-4 py-3 text-sm">
{u.lastLoginAt ? (
<span className="text-foreground/80">{formatDateTime(u.lastLoginAt)}</span>
) : (
<span className="text-amber-600 text-xs">Nie eingeloggt</span>
)}
</td>
<td className="px-4 py-3">
{inactive ? (
<span className="text-xs px-2 py-1 rounded-full font-medium bg-red-100 text-red-700">Gesperrt</span>
) : u.emailVerified === false ? (
<span className="text-xs px-2 py-1 rounded-full font-medium bg-amber-100 text-amber-700">Unverifiziert</span>
) : (
<span className="text-xs px-2 py-1 rounded-full font-medium bg-green-100 text-green-700">Aktiv</span>
)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
{u.emailVerified === false && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-600" title="E-Mail manuell verifizieren" onClick={() => handleToggleUserVerified(u)}>
<ShieldCheck className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" title="Passwort zurücksetzen" onClick={() => handleResetUserPassword(u)}>
<KeyRound className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Zwei-Faktor (2FA) zurücksetzen" onClick={() => handleResetUserMfa(u)}>
<ShieldCheck className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Bearbeiten" onClick={() => openEditUser(u)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className={`h-8 w-8 ${inactive ? 'text-green-600' : 'text-amber-600'}`}
title={inactive ? 'Entsperren' : 'Sperren'} onClick={() => handleToggleUserActive(u)}
disabled={u.id === user?.id || u.email === 'admin@lageplan.local'}>
{inactive ? <CheckCircle className="w-4 h-4" /> : <Ban className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" title="Löschen" onClick={() => handleDeleteUser(u.id)}
disabled={u.email === 'admin@lageplan.local'}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
)})}
{filteredUsers.length === 0 && (
<tr><td colSpan={7} className="px-4 py-8 text-center text-sm text-muted-foreground">Keine Benutzer gefunden.</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* User Dialog */}
<Dialog open={isUserDialogOpen} onOpenChange={setIsUserDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Name</Label><Input value={userName} onChange={e => setUserName(e.target.value)} placeholder="Vor- und Nachname" /></div>
<div><Label>E-Mail</Label><Input type="email" value={userEmail} onChange={e => setUserEmail(e.target.value)} placeholder="name@example.com" /></div>
<div>
<Label>{editingUser ? 'Neues Passwort (leer = unverändert)' : 'Passwort'}</Label>
<Input type="password" value={userPassword} onChange={e => setUserPassword(e.target.value)} placeholder={editingUser ? '••••••••' : 'Min. 6 Zeichen'} />
</div>
<div>
<Label>Passwort bestätigen</Label>
<Input type="password" value={userPasswordConfirm} onChange={e => setUserPasswordConfirm(e.target.value)} placeholder="Passwort wiederholen" />
{userPassword && userPasswordConfirm && userPassword !== userPasswordConfirm && (
<p className="text-xs text-destructive mt-1">Passwörter stimmen nicht überein</p>
)}
</div>
<div>
<Label>Rolle</Label>
<Select value={userRole} onValueChange={setUserRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ROLES.filter(r => isServerAdmin || r.value !== 'SERVER_ADMIN').map(r => (
<SelectItem key={r.value} value={r.value}>
<div>
<span className="font-medium">{r.label}</span>
<span className="text-xs text-muted-foreground ml-2"> {r.desc}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Mandanten-Auswahl: nur beim Anlegen durch Super-Admin für Nicht-Server-Admins */}
{!editingUser && isServerAdmin && userRole !== 'SERVER_ADMIN' && (
<div>
<Label>Mandant</Label>
<Select value={userTenantId} onValueChange={setUserTenantId}>
<SelectTrigger><SelectValue placeholder="Mandant wählen (empfohlen)" /></SelectTrigger>
<SelectContent>
{tenants.map(t => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">Ohne Mandant hat der Benutzer keine Organisation.</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsUserDialogOpen(false)}>Abbrechen</Button>
<Button onClick={handleSaveUser} disabled={!userName.trim() || !userEmail.trim() || (!editingUser && !userPassword) || (!!userPassword && userPassword !== userPasswordConfirm)}>
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}