Passwort ändern (eigene) und zurücksetzen (Admin), inkl. Sitzungs-Invalidierung
This commit is contained in:
1 parent
f52f93b663
commit
780b735dfa
5 files changed
+229
-18
No files matched your search
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user