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 [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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user