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:
@@ -15,8 +15,12 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
|||||||
- [x] **0.2 – Token-Laufzeit verkürzt** 🟢 ✅
|
- [x] **0.2 – Token-Laufzeit verkürzt** 🟢 ✅
|
||||||
- „Angemeldet bleiben" 30 → 14 Tage. Laufzeiten zentralisiert (`SESSION_MAX_AGE_*` in `auth.ts`).
|
- „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)._
|
- _Offen für später: automatische Verlängerung bei Aktivität + serverseitiger Widerruf (grösserer Umbau, braucht Live-Test)._
|
||||||
- [ ] **0.3 – Tenant-Admin-UX** 🟡
|
- [~] **0.3 – Benutzer-/Mandanten-Verwaltung** 🟡 _(Teil 1 erledigt)_
|
||||||
- Mitglieder einladen / Rolle ändern / deaktivieren. APIs existieren bereits.
|
- ✅ 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
|
## Phase 1 – Einsatz vs. Übung
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.5",
|
"version": "1.4.6",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ async function migrate() {
|
|||||||
// Users
|
// Users
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMP`,
|
`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 "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 "emailVerificationToken" TEXT`,
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetToken" TEXT`,
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetToken" TEXT`,
|
||||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetTokenExpiry" TIMESTAMP`,
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "resetTokenExpiry" TIMESTAMP`,
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ model User {
|
|||||||
name String
|
name String
|
||||||
role Role @default(OPERATOR)
|
role Role @default(OPERATOR)
|
||||||
emailVerified Boolean @default(true)
|
emailVerified Boolean @default(true)
|
||||||
|
// Super-Admin kann einzelne Benutzer sperren; gesperrte können sich nicht mehr anmelden.
|
||||||
|
isActive Boolean @default(true)
|
||||||
emailVerificationToken String? @unique
|
emailVerificationToken String? @unique
|
||||||
resetToken String? @unique
|
resetToken String? @unique
|
||||||
resetTokenExpiry DateTime?
|
resetTokenExpiry DateTime?
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { formatDateTime } from '@/lib/utils'
|
||||||
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
|
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
|
||||||
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
|
||||||
import { SettingsTab } from '@/components/admin/settings-tab'
|
import { SettingsTab } from '@/components/admin/settings-tab'
|
||||||
@@ -92,6 +93,8 @@ interface UserRecord {
|
|||||||
name: string
|
name: string
|
||||||
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
|
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
lastLoginAt?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
memberships?: { tenant: { id: string; name: string; slug: string } }[]
|
memberships?: { tenant: { id: string; name: string; slug: string } }[]
|
||||||
@@ -454,6 +457,25 @@ export default function AdminPage() {
|
|||||||
} catch { toast({ title: 'Fehler', 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' })
|
||||||
|
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) => {
|
const handleResetUserPassword = async (targetUser: UserRecord) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/users/${targetUser.id}/reset-password`, { method: 'POST' })
|
const res = await fetch(`/api/admin/users/${targetUser.id}/reset-password`, { method: 'POST' })
|
||||||
@@ -564,7 +586,7 @@ export default function AdminPage() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="users" className="gap-2">
|
<TabsTrigger value="users" className="gap-2">
|
||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
Server Admins
|
Benutzer
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="settings" className="gap-2">
|
<TabsTrigger value="settings" className="gap-2">
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
@@ -716,10 +738,9 @@ export default function AdminPage() {
|
|||||||
<TabsContent value="users" className="space-y-4">
|
<TabsContent value="users" className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{user?.role === 'SERVER_ADMIN'
|
{users.length} Benutzer
|
||||||
? `${users.filter(u => u.role === 'SERVER_ADMIN').length} Server-Admin(s)`
|
{users.filter(u => u.isActive === false).length > 0 &&
|
||||||
: `${users.length} Benutzer`
|
` · ${users.filter(u => u.isActive === false).length} gesperrt`}
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => {
|
<Button onClick={() => {
|
||||||
setEditingUser(null); setUserName(''); setUserEmail(''); setUserPassword(''); setUserPasswordConfirm(''); setUserRole('OPERATOR'); setIsUserDialogOpen(true)
|
setEditingUser(null); setUserName(''); setUserEmail(''); setUserPassword(''); setUserPasswordConfirm(''); setUserRole('OPERATOR'); setIsUserDialogOpen(true)
|
||||||
@@ -728,20 +749,25 @@ export default function AdminPage() {
|
|||||||
Neuer Benutzer
|
Neuer Benutzer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<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">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">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">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>
|
<th className="text-right text-xs font-medium text-muted-foreground px-4 py-3">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{(user?.role === 'SERVER_ADMIN' ? users.filter(u => u.role === 'SERVER_ADMIN') : users).map(u => (
|
{users.map(u => {
|
||||||
<tr key={u.id} className="hover:bg-muted/30 transition-colors">
|
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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<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 ${
|
<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>
|
<span className="font-medium text-sm">{u.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
<td className="px-4 py-3 text-sm text-muted-foreground">{u.email}</td>
|
||||||
<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">
|
<td className="px-4 py-3">
|
||||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||||
u.role === 'SERVER_ADMIN' ? 'bg-red-100 text-red-700' :
|
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}
|
{ROLES.find(r => r.value === u.role)?.label || u.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
{u.emailVerified === false && (
|
{u.emailVerified === false && (
|
||||||
@@ -783,19 +820,25 @@ export default function AdminPage() {
|
|||||||
onClick={() => handleResetUserPassword(u)}>
|
onClick={() => handleResetUserPassword(u)}>
|
||||||
<KeyRound className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
</Button>
|
</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)
|
setEditingUser(u); setUserName(u.name); setUserEmail(u.email); setUserPassword(''); setUserPasswordConfirm(''); setUserRole(u.role); setIsUserDialogOpen(true)
|
||||||
}}>
|
}}>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</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'}>
|
disabled={u.email === 'admin@lageplan.local'}>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const updateUserSchema = z.object({
|
|||||||
password: z.string().min(6).optional(),
|
password: z.string().min(6).optional(),
|
||||||
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']).optional(),
|
role: z.enum(['SERVER_ADMIN', 'TENANT_ADMIN', 'OPERATOR', 'VIEWER']).optional(),
|
||||||
emailVerified: z.boolean().optional(),
|
emailVerified: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
@@ -46,6 +47,13 @@ export async function PATCH(
|
|||||||
if (data.role) updateData.role = data.role
|
if (data.role) updateData.role = data.role
|
||||||
if (data.password) updateData.password = await hashPassword(data.password)
|
if (data.password) updateData.password = await hashPassword(data.password)
|
||||||
if (data.emailVerified !== undefined) updateData.emailVerified = data.emailVerified
|
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({
|
const user = await (prisma as any).user.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
@@ -56,6 +64,8 @@ export async function PATCH(
|
|||||||
name: true,
|
name: true,
|
||||||
role: true,
|
role: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export async function GET() {
|
|||||||
name: true,
|
name: true,
|
||||||
role: true,
|
role: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
memberships: {
|
memberships: {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export async function login(
|
|||||||
password: true,
|
password: true,
|
||||||
role: true,
|
role: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
|
isActive: true,
|
||||||
},
|
},
|
||||||
}) as any)
|
}) as any)
|
||||||
|
|
||||||
@@ -71,6 +72,12 @@ export async function login(
|
|||||||
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
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)
|
// Track email verification status (allow login regardless)
|
||||||
const emailVerified = (user as any).emailVerified !== false
|
const emailVerified = (user as any).emailVerified !== false
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user