Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
- 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>
349 lines
17 KiB
TypeScript
349 lines
17 KiB
TypeScript
'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>
|
||
)
|
||
}
|