diff --git a/ROADMAP.md b/ROADMAP.md index 87535ee..cf5e8c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/package.json b/package.json index b32308b..82f38b0 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/prisma/migrate.js b/prisma/migrate.js index acd203c..b7f607c 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -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`, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fcb7ddb..ce605c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f5a6b4a..8563ae6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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() { - Server Admins + Benutzer @@ -716,10 +738,9 @@ export default function AdminPage() {

- {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`}

-
+
- + + + - {(user?.role === 'SERVER_ADMIN' ? users.filter(u => u.role === 'SERVER_ADMIN') : users).map(u => ( - + {users.map(u => { + const inactive = u.isActive === false + const tenantNames = (u.memberships || []).map(m => m.tenant?.name).filter(Boolean) + return ( + - + - + + + - ))} + )})}
Name E-Mail RolleProjekteMandantLetzte AnmeldungStatus Aktionen
{u.name}
-
- {u.email} - {u.emailVerified === false && ( - unverifiziert - )} -
-
{u.email} r.value === u.role)?.label || u.role} {u._count?.projects || 0} + {tenantNames.length > 0 ? tenantNames.join(', ') : —} + + {u.lastLoginAt ? ( + {formatDateTime(u.lastLoginAt)} + ) : ( + Nie eingeloggt + )} + + {inactive ? ( + Gesperrt + ) : u.emailVerified === false ? ( + Unverifiziert + ) : ( + Aktiv + )} +
{u.emailVerified === false && ( @@ -783,19 +820,25 @@ export default function AdminPage() { onClick={() => handleResetUserPassword(u)}> - - +
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index c5cf44d..59af631 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -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, }, diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index d8c6148..bbcaa74 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -31,6 +31,8 @@ export async function GET() { name: true, role: true, emailVerified: true, + isActive: true, + lastLoginAt: true, createdAt: true, updatedAt: true, memberships: { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 98cd3a2..ef7cd6e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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