feat(admin): echtes Super-Admin-Benutzer-Management + letzte Anmeldung
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
- Super-Admin sieht jetzt ALLE Benutzer über alle Mandanten in einer Tabelle
(statt nur Server-Admins). Neue Spalten: Mandant, Letzte Anmeldung, Status
- Einzelne Benutzer sperren/entsperren: User.isActive (Schema + Auto-Migration),
Login-Sperre für deaktivierte Konten, PATCH /api/admin/users/[id] { isActive }
- GET /api/admin/users liefert jetzt lastLoginAt + isActive
- Selbstsperre verhindert; admin@lageplan.local geschützt
- Version 1.4.6
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,7 @@ import {
|
||||
Building2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
|
||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||
import { SettingsTab } from '@/components/admin/settings-tab'
|
||||
@@ -92,6 +93,8 @@ interface UserRecord {
|
||||
name: string
|
||||
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
|
||||
emailVerified?: boolean
|
||||
isActive?: boolean
|
||||
lastLoginAt?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memberships?: { tenant: { id: string; name: string; slug: string } }[]
|
||||
@@ -454,6 +457,25 @@ export default function AdminPage() {
|
||||
} 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' })
|
||||
fetchData()
|
||||
} 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' })
|
||||
@@ -564,7 +586,7 @@ export default function AdminPage() {
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Server Admins
|
||||
Benutzer
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
@@ -716,10 +738,9 @@ export default function AdminPage() {
|
||||
<TabsContent value="users" className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user?.role === 'SERVER_ADMIN'
|
||||
? `${users.filter(u => u.role === 'SERVER_ADMIN').length} Server-Admin(s)`
|
||||
: `${users.length} Benutzer`
|
||||
}
|
||||
{users.length} Benutzer
|
||||
{users.filter(u => u.isActive === false).length > 0 &&
|
||||
` · ${users.filter(u => u.isActive === false).length} gesperrt`}
|
||||
</p>
|
||||
<Button onClick={() => {
|
||||
setEditingUser(null); setUserName(''); setUserEmail(''); setUserPassword(''); setUserPasswordConfirm(''); setUserRole('OPERATOR'); setIsUserDialogOpen(true)
|
||||
@@ -728,20 +749,25 @@ export default function AdminPage() {
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<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">Projekte</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">
|
||||
{(user?.role === 'SERVER_ADMIN' ? users.filter(u => u.role === 'SERVER_ADMIN') : users).map(u => (
|
||||
<tr key={u.id} className="hover:bg-muted/30 transition-colors">
|
||||
{users.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 ${
|
||||
@@ -752,14 +778,7 @@ export default function AdminPage() {
|
||||
<span className="font-medium text-sm">{u.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{u.email}
|
||||
{u.emailVerified === false && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">unverifiziert</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' :
|
||||
@@ -770,7 +789,25 @@ export default function AdminPage() {
|
||||
{ROLES.find(r => r.value === u.role)?.label || u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{u._count?.projects || 0}</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 && (
|
||||
@@ -783,19 +820,25 @@ export default function AdminPage() {
|
||||
onClick={() => handleResetUserPassword(u)}>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => {
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Bearbeiten" onClick={() => {
|
||||
setEditingUser(u); setUserName(u.name); setUserEmail(u.email); setUserPassword(''); setUserPasswordConfirm(''); setUserRole(u.role); setIsUserDialogOpen(true)
|
||||
}}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDeleteUser(u.id)}
|
||||
<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>
|
||||
))}
|
||||
)})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ const updateUserSchema = z.object({
|
||||
password: z.string().min(6).optional(),
|
||||
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']).optional(),
|
||||
emailVerified: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function PATCH(
|
||||
@@ -46,6 +47,13 @@ export async function PATCH(
|
||||
if (data.role) updateData.role = data.role
|
||||
if (data.password) updateData.password = await hashPassword(data.password)
|
||||
if (data.emailVerified !== undefined) updateData.emailVerified = data.emailVerified
|
||||
if (data.isActive !== undefined) {
|
||||
// Niemand darf sich selbst sperren
|
||||
if (data.isActive === false && session.id === params.id) {
|
||||
return NextResponse.json({ error: 'Eigenen Account kann man nicht sperren' }, { status: 400 })
|
||||
}
|
||||
updateData.isActive = data.isActive
|
||||
}
|
||||
|
||||
const user = await (prisma as any).user.update({
|
||||
where: { id: params.id },
|
||||
@@ -56,6 +64,8 @@ export async function PATCH(
|
||||
name: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ export async function GET() {
|
||||
name: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
memberships: {
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function login(
|
||||
password: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
},
|
||||
}) as any)
|
||||
|
||||
@@ -71,6 +72,12 @@ export async function login(
|
||||
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
||||
}
|
||||
|
||||
// Gesperrte Benutzer dürfen sich nicht anmelden (isActive === false).
|
||||
// Tolerant: nur blockieren, wenn explizit false (alte Datensätze ohne Wert bleiben aktiv).
|
||||
if (user.isActive === false) {
|
||||
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
|
||||
}
|
||||
|
||||
// Track email verification status (allow login regardless)
|
||||
const emailVerified = (user as any).emailVerified !== false
|
||||
|
||||
|
||||
Reference in New Issue
Block a user