feat(admin): echtes Super-Admin-Benutzer-Management + letzte Anmeldung
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:
Pepe Ziberi
2026-07-18 23:15:16 +02:00
parent bae5a36492
commit d08b96a42a
8 changed files with 93 additions and 24 deletions

View File

@@ -15,8 +15,12 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
- [x] **0.2 Token-Laufzeit verkürzt** 🟢 ✅
- „Angemeldet bleiben" 30 → 14 Tage. Laufzeiten zentralisiert (`SESSION_MAX_AGE_*` in `auth.ts`).
- _Offen für später: automatische Verlängerung bei Aktivität + serverseitiger Widerruf (grösserer Umbau, braucht Live-Test)._
- [ ] **0.3 Tenant-Admin-UX** 🟡
- Mitglieder einladen / Rolle ändern / deaktivieren. APIs existieren bereits.
- [~] **0.3 Benutzer-/Mandanten-Verwaltung** 🟡 _(Teil 1 erledigt)_
- ✅ Super-Admin sieht jetzt ALLE Benutzer über alle Mandanten in einer Tabelle
(Mandant-Spalte, letzte Anmeldung, Status), kann einzeln sperren/entsperren
(`User.isActive`, Login-Sperre) und Passwort zurücksetzen.
- Offen: die grosse `admin/page.tsx` (1365 Z.) strukturell entwirren (7 Domänen
auf einem Screen) + Rollen-Dualität (User.role vs. Membership.role) vereinheitlichen.
## Phase 1 Einsatz vs. Übung

View File

@@ -1,6 +1,6 @@
{
"name": "lageplan",
"version": "1.4.5",
"version": "1.4.6",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {

View File

@@ -59,6 +59,7 @@ async function migrate() {
// Users
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMP`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "emailVerified" BOOLEAN DEFAULT true`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "emailVerificationToken" TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetToken" TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetTokenExpiry" TIMESTAMP`,

View File

@@ -118,6 +118,8 @@ model User {
name String
role Role @default(OPERATOR)
emailVerified Boolean @default(true)
// Super-Admin kann einzelne Benutzer sperren; gesperrte können sich nicht mehr anmelden.
isActive Boolean @default(true)
emailVerificationToken String? @unique
resetToken String? @unique
resetTokenExpiry DateTime?

View File

@@ -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>

View File

@@ -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,
},

View File

@@ -31,6 +31,8 @@ export async function GET() {
name: true,
role: true,
emailVerified: true,
isActive: true,
lastLoginAt: true,
createdAt: true,
updatedAt: true,
memberships: {

View File

@@ -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