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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-20 11:22:11 +02:00
parent a63c1069dd
commit d277789aa6
3 changed files with 162 additions and 7 deletions
+89
View File
@@ -59,6 +59,9 @@ export default function AdminUsersPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingUserId, setSavingUserId] = useState<string | null>(null); const [savingUserId, setSavingUserId] = useState<string | null>(null);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null); const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [resetPasswordUserId, setResetPasswordUserId] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState("");
const [resettingPassword, setResettingPassword] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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; const adminCount = users.filter((user) => user.role === "ADMIN").length;
return ( return (
@@ -362,6 +402,14 @@ export default function AdminUsersPage() {
Bearbeiten Bearbeiten
</button> </button>
<button
type="button"
className="button buttonSecondary adminTableButton"
onClick={() => openResetPassword(user.id)}
>
Passwort
</button>
<button <button
type="button" type="button"
className="button buttonDanger adminTableButton" className="button buttonDanger adminTableButton"
@@ -389,6 +437,47 @@ export default function AdminUsersPage() {
) : null} ) : null}
</div> </div>
</section> </section>
{resetPasswordUserId ? (
<div className="modalOverlay" onClick={() => setResetPasswordUserId(null)}>
<div className="modalContent" onClick={(e) => e.stopPropagation()}>
<h2>Passwort zurücksetzen</h2>
<p className="muted">
Neues Passwort für{" "}
<strong>{users.find((u) => u.id === resetPasswordUserId)?.email}</strong>
</p>
<form onSubmit={(e) => void resetPassword(e)}>
<input
className="input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Neues Passwort (min. 10 Zeichen)"
minLength={10}
maxLength={128}
required
autoFocus
/>
<div className="modalActions">
<button type="submit" className="button" disabled={resettingPassword}>
{resettingPassword ? "Speichere..." : "Passwort setzen"}
</button>
<button
type="button"
className="button buttonSecondary"
onClick={() => setResetPasswordUserId(null)}
disabled={resettingPassword}
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
) : null}
</main> </main>
); );
} }
+37 -7
View File
@@ -1,8 +1,10 @@
import { rm } from "fs/promises"; import { rm } from "fs/promises";
import path from "path"; import path from "path";
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireCurrentUser, UnauthorizedError } from "@/lib/auth"; import { requireCurrentUser, UnauthorizedError } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { normalizePassword } from "@/lib/validation";
type RouteContext = { type RouteContext = {
params: Promise<{ params: Promise<{
@@ -79,19 +81,13 @@ export async function PATCH(request: Request, context: RouteContext) {
const body = (await request.json().catch(() => null)) as { const body = (await request.json().catch(() => null)) as {
email?: unknown; email?: unknown;
displayName?: unknown; displayName?: unknown;
password?: unknown;
} | null; } | null;
if (!body) { if (!body) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); 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({ const existingUser = await prisma.user.findUnique({
where: { where: {
id: userId id: userId
@@ -102,6 +98,40 @@ export async function PATCH(request: Request, context: RouteContext) {
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 }); 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({ const emailOwner = await prisma.user.findUnique({
where: { where: {
email email
+36
View File
@@ -901,6 +901,42 @@ button:disabled {
margin: 0; 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 { .logoUploadLayout {
display: grid; display: grid;
grid-template-columns: 160px minmax(0, 1fr); grid-template-columns: 160px minmax(0, 1fr);