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:
@@ -59,6 +59,9 @@ export default function AdminUsersPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingUserId, setSavingUserId] = 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 [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;
|
||||
|
||||
return (
|
||||
@@ -362,6 +402,14 @@ export default function AdminUsersPage() {
|
||||
Bearbeiten
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="button buttonSecondary adminTableButton"
|
||||
onClick={() => openResetPassword(user.id)}
|
||||
>
|
||||
Passwort
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="button buttonDanger adminTableButton"
|
||||
@@ -389,6 +437,47 @@ export default function AdminUsersPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user