Passwort ändern (eigene) und zurücksetzen (Admin), inkl. Sitzungs-Invalidierung

This commit is contained in:
loki committed 2026-10-18 20:37:21 +02:00
1 parent f52f93b663
commit 780b735dfa
5 files changed
+229 -18

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { getDb } from "@/lib/db";
import { requireAdmin } from "@/lib/authz";
const MIN_LENGTH = 8;
/** Passwort eines anderen Nutzers zurücksetzen (Admin). Nur für fremde Konten; meldet deren Sitzungen ab. */
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 });
if (numId === admin.id) {
return NextResponse.json({ errors: ["id"], message: "Eigenes Passwort bitte über „Eigenes Passwort“ ändern." }, { status: 400 });
}
const body = await req.json().catch(() => ({}));
const newPassword = typeof body.newPassword === "string" ? body.newPassword : "";
if (newPassword.length < MIN_LENGTH) {
return NextResponse.json({ errors: ["newPassword"], message: "Das neue Passwort braucht mindestens 8 Zeichen." }, { status: 400 });
}
const db = getDb();
const target = await db.user.findUnique({ where: { id: numId } });
if (!target) return NextResponse.json({ error: "not found" }, { status: 404 });
await db.user.update({
where: { id: numId },
data: { passwordHash: await bcrypt.hash(newPassword, 10), tokenVersion: { increment: 1 } },
});
return NextResponse.json({ ok: true });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { getDb } from "@/lib/db";
import { getSessionUser } from "@/lib/authz";
import { createSession } from "@/lib/session";
import { setSessionCookie } from "@/lib/set-session-cookie";
const MIN_LENGTH = 8;
/** Eigenes Passwort ändern. Meldet alle anderen Sitzungen ab. */
export async function POST(req: NextRequest) {
const me = await getSessionUser();
if (!me) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const body = await req.json().catch(() => ({}));
const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : "";
const newPassword = typeof body.newPassword === "string" ? body.newPassword : "";
if (newPassword.length < MIN_LENGTH) {
return NextResponse.json({ errors: ["newPassword"], message: "Das neue Passwort braucht mindestens 8 Zeichen." }, { status: 400 });
}
const db = getDb();
const user = await db.user.findUnique({ where: { id: me.id } });
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
if (user.passwordHash) {
if (!currentPassword || !(await bcrypt.compare(currentPassword, user.passwordHash))) {
return NextResponse.json({ errors: ["currentPassword"], message: "Das aktuelle Passwort stimmt nicht." }, { status: 400 });
}
}
const updated = await db.user.update({
where: { id: me.id },
data: { passwordHash: await bcrypt.hash(newPassword, 10), tokenVersion: { increment: 1 } },
});
const token = await createSession({ uid: updated.id, name: updated.name, role: updated.role, tokenVersion: updated.tokenVersion });
const res = NextResponse.json({ ok: true });
setSessionCookie(res, token);
return res;
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
const inputClass =
"mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
export default function OwnPasswordForm() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setNotice("");
setSaving(true);
try {
const res = await fetch("/api/users/me/password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setNotice(data.message ?? "Speichern fehlgeschlagen.");
return;
}
setCurrentPassword("");
setNewPassword("");
setNotice("Passwort geändert. Andere Sitzungen wurden abgemeldet.");
} catch {
setNotice("Speichern fehlgeschlagen.");
} finally {
setSaving(false);
}
}
return (
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Eigenes Passwort</h2>
<p className="mt-1 text-[13px] text-muted">Mindestens 8 Zeichen. Andere Sitzungen werden dabei abgemeldet.</p>
<form onSubmit={onSubmit} className="mt-3 grid gap-3 sm:grid-cols-2" noValidate>
<div>
<label htmlFor="own-current-password" className="block text-[13px] font-semibold">Aktuelles Passwort</label>
<input
id="own-current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
placeholder="Leer lassen, falls keins gesetzt"
className={inputClass}
/>
</div>
<div>
<label htmlFor="own-new-password" className="block text-[13px] font-semibold">Neues Passwort</label>
<input
id="own-new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
placeholder="Mindestens 8 Zeichen"
className={inputClass}
/>
</div>
<div className="sm:col-span-2">
<button
type="submit"
disabled={saving}
className="h-11 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700 disabled:opacity-60"
>
{saving ? "Wird gespeichert …" : "Passwort ändern"}
</button>
</div>
</form>
{notice && (
<p role="status" className="mt-3 text-sm font-medium text-muted">
{notice}
</p>
)}
</section>
);
}
+2
View File
@@ -4,6 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import Link from "next/link";
import UsersSection from "./users";
import OwnPasswordForm from "./account";
import { HouseholdPanel } from "./household-panel";
import ServerHouseholds from "./server-households";
import { TestButtons } from "./test-buttons";
@@ -138,6 +139,7 @@ function TabsInner({
</p>
</section>
)}
<OwnPasswordForm />
</>
)}
+75 -18
View File
@@ -28,6 +28,8 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
const [inviteLink, setInviteLink] = useState("");
const [notice, setNotice] = useState("");
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
const [resetFor, setResetFor] = useState<number | null>(null);
const [resetPw, setResetPw] = useState("");
async function load() {
const [u, inv, hh] = await Promise.all([
@@ -79,6 +81,27 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
load();
}
async function resetPassword(id: number) {
setNotice("");
if (resetPw.length < 8) {
setNotice("Das neue Passwort braucht mindestens 8 Zeichen.");
return;
}
const res = await fetch(`/api/users/${id}/password`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ newPassword: resetPw }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setNotice(data.message ?? "Zurücksetzen fehlgeschlagen.");
return;
}
setResetFor(null);
setResetPw("");
setNotice("Passwort zurückgesetzt. Die Sitzungen dieses Kontos wurden abgemeldet.");
}
async function saveMyEmail() {
setNotice("");
const res = await fetch("/api/users/me", {
@@ -103,29 +126,63 @@ export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEm
) : (
<ul className="mt-3 divide-y divide-line">
{users.map((u) => (
<li key={u.id} className="flex items-center justify-between gap-3 py-2.5 text-sm">
<div className="min-w-0">
<p className="font-semibold">
{u.name}
{u.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
</p>
<p className="truncate text-[13px] text-muted">
{u.email ?? "keine E-Mail"} · {u.role === "admin" ? "Server-Admin" : "Mitglied"}
{u.oidcVerknuepft ? " · SSO verknüpft" : ""}
</p>
<p className="truncate text-[13px] text-muted">
{u.householdName ? `Haushalt: ${u.householdName}${u.householdRole === "admin" ? " (Admin)" : ""}` : "Kein Haushalt"}
</p>
<li key={u.id} className="py-2.5 text-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold">
{u.name}
{u.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
</p>
<p className="truncate text-[13px] text-muted">
{u.email ?? "keine E-Mail"} · {u.role === "admin" ? "Server-Admin" : "Mitglied"}
{u.oidcVerknuepft ? " · SSO verknüpft" : ""}
</p>
<p className="truncate text-[13px] text-muted">
{u.householdName ? `Haushalt: ${u.householdName}${u.householdRole === "admin" ? " (Admin)" : ""}` : "Kein Haushalt"}
</p>
</div>
{u.id !== ownId ? (
<span className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => {
setResetFor(resetFor === u.id ? null : u.id);
setResetPw("");
}}
className="rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-brand-700"
>
Passwort
</button>
<button
type="button"
onClick={() => removeUser(u.id, u.name)}
className="rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Entfernen
</button>
</span>
) : null}
</div>
{u.id !== ownId && (
{resetFor === u.id && (
<div className="mt-2 flex gap-2">
<input
type="password"
value={resetPw}
onChange={(e) => setResetPw(e.target.value)}
autoComplete="new-password"
placeholder="Neues Passwort (min. 8 Zeichen)"
aria-label={`Neues Passwort für ${u.name}`}
className="h-10 min-w-0 flex-1 rounded-field border border-line bg-white px-3 text-sm text-ink"
/>
<button
type="button"
onClick={() => removeUser(u.id, u.name)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
onClick={() => resetPassword(u.id)}
className="h-10 shrink-0 rounded-field bg-brand-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Entfernen
Setzen
</button>
)}
</div>
)}
</li>
))}
</ul>