From d277789aa627ef456c8fc5020c358d01253bfcd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:22:11 +0200 Subject: [PATCH] Add admin password reset for users - Extend PATCH /api/users/[id] to accept optional password field - Hash with bcrypt (cost 12), invalidate target user's sessions - Add "Passwort" button and modal dialog in admin user management - Validate password length (10-128 chars) via existing normalizePassword Co-Authored-By: Claude Sonnet 4.6 --- src/app/admin/users/page.tsx | 89 +++++++++++++++++++++++++++++++++ src/app/api/users/[id]/route.ts | 44 +++++++++++++--- src/app/globals.css | 36 +++++++++++++ 3 files changed, 162 insertions(+), 7 deletions(-) diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 8c9b6ac..74148c6 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -59,6 +59,9 @@ export default function AdminUsersPage() { const [loading, setLoading] = useState(true); const [savingUserId, setSavingUserId] = useState(null); const [deletingUserId, setDeletingUserId] = useState(null); + const [resetPasswordUserId, setResetPasswordUserId] = useState(null); + const [newPassword, setNewPassword] = useState(""); + const [resettingPassword, setResettingPassword] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); @@ -235,6 +238,43 @@ export default function AdminUsersPage() { } } + function openResetPassword(userId: string) { + setResetPasswordUserId(userId); + setNewPassword(""); + setError(null); + setMessage(null); + } + + async function resetPassword(event: FormEvent) { + event.preventDefault(); + + if (!resetPasswordUserId) { + return; + } + + setResettingPassword(true); + setError(null); + setMessage(null); + + try { + const response = await fetch(`/api/users/${encodeURIComponent(resetPasswordUserId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: newPassword }) + }); + + await parseJsonResponse<{ ok: boolean }>(response); + + setResetPasswordUserId(null); + setNewPassword(""); + setMessage("Passwort wurde zurückgesetzt."); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Passwort konnte nicht zurückgesetzt werden."); + } finally { + setResettingPassword(false); + } + } + const adminCount = users.filter((user) => user.role === "ADMIN").length; return ( @@ -362,6 +402,14 @@ export default function AdminUsersPage() { Bearbeiten + + + + + + + + + ) : null} ); } diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index a29718b..78eb40b 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -1,8 +1,10 @@ import { rm } from "fs/promises"; import path from "path"; +import bcrypt from "bcryptjs"; import { NextResponse } from "next/server"; import { requireCurrentUser, UnauthorizedError } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { normalizePassword } from "@/lib/validation"; type RouteContext = { params: Promise<{ @@ -79,19 +81,13 @@ export async function PATCH(request: Request, context: RouteContext) { const body = (await request.json().catch(() => null)) as { email?: unknown; displayName?: unknown; + password?: unknown; } | null; if (!body) { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); } - const email = normalizeEmail(body.email); - const displayName = normalizeDisplayName(body.displayName); - - if (!email) { - return NextResponse.json({ error: "E-Mail ist ungültig." }, { status: 400 }); - } - const existingUser = await prisma.user.findUnique({ where: { id: userId @@ -102,6 +98,40 @@ export async function PATCH(request: Request, context: RouteContext) { return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 }); } + if (body.password !== undefined) { + const password = normalizePassword(body.password); + + if (!password) { + return NextResponse.json( + { error: "Passwort muss zwischen 10 und 128 Zeichen lang sein." }, + { status: 400 } + ); + } + + const passwordHash = await bcrypt.hash(password, 12); + + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash } + }); + + await prisma.session.deleteMany({ + where: { + userId, + ...(userId === currentUser.id ? {} : undefined) + } + }); + + return NextResponse.json({ ok: true, message: "Passwort wurde zurückgesetzt." }); + } + + const email = normalizeEmail(body.email); + const displayName = normalizeDisplayName(body.displayName); + + if (!email) { + return NextResponse.json({ error: "E-Mail ist ungültig." }, { status: 400 }); + } + const emailOwner = await prisma.user.findUnique({ where: { email diff --git a/src/app/globals.css b/src/app/globals.css index a052461..df4cf4f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -901,6 +901,42 @@ button:disabled { margin: 0; } +.modalOverlay { + position: fixed; + inset: 0; + z-index: 10000; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.5); +} + +.modalContent { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 24px; + width: 100%; + max-width: 420px; + display: grid; + gap: 14px; +} + +.modalContent h2 { + margin: 0; + font-size: 20px; +} + +.modalContent .input { + width: 100%; +} + +.modalActions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 4px; +} + .logoUploadLayout { display: grid; grid-template-columns: 160px minmax(0, 1fr);