Passwort-Reset per Einmal-Link (kver reset-link)

Ohne E-Mail-System erzeugt der Admin per CLI einen Link (/reset#<token>),
gültig 72 h und einmal. Gespeichert wird nur der SHA-256 des Tokens; es
steht im Fragment und geht nur im POST-Body an die API. Der Reset meldet
alle alten Sessions ab und loggt direkt neu ein. Passwortregeln jetzt
gemeinsam in checkNewPassword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-10-01 21:56:05 +02:00
co-authored by Claude Opus 5.5
parent b6cf8c7fa0
commit 81a74375b7
12 changed files with 408 additions and 14 deletions
+2
View File
@@ -8,6 +8,7 @@ import { FeedPage } from "./pages/FeedPage";
import { EntryPage } from "./pages/EntryPage";
import { UserPage } from "./pages/UserPage";
import { StatsPage } from "./pages/StatsPage";
import { ResetPage } from "./pages/ResetPage";
import "./app.css";
const queryClient = new QueryClient({
@@ -31,6 +32,7 @@ const router = createBrowserRouter([
{ path: "/e/:pid", element: <EntryPage /> },
{ path: "/u/:username", element: <UserPage /> },
{ path: "/stats", element: <StatsPage /> },
{ path: "/reset", element: <ResetPage /> },
],
},
]);
+65
View File
@@ -0,0 +1,65 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { useAuthChanged } from "../auth";
// ResetPage: neues Passwort über einen Reset-Link (/reset#<token>, erzeugt per
// kver reset-link). Das Token steht im Fragment, damit es nie in einer URL beim
// Server landet; es geht nur im POST-Body raus.
export function ResetPage() {
const [token] = useState(() => window.location.hash.slice(1));
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const authChanged = useAuthChanged();
const check = useQuery({
queryKey: ["reset", token],
queryFn: () => {
const fd = new FormData();
fd.append("token", token);
return api.post<{ username: string }>("/auth/reset/check", fd);
},
enabled: token !== "",
staleTime: Infinity,
});
// Nach Erfolg ist man eingeloggt -> ab in den Feed.
const submit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
fd.append("token", token);
setBusy(true);
try {
await api.post("/auth/reset", fd);
await authChanged();
navigate("/");
} catch (err) {
setMsg(errMsg(err, "Passwort konnte nicht gesetzt werden."));
} finally {
setBusy(false);
}
};
let error = "";
if (token === "") error = "Der Link ist unvollständig.";
else if (check.isError) error = errMsg(check.error, "Der Link konnte nicht geprüft werden.");
return (
<section id="reset">
<h2>Passwort neu setzen</h2>
{error && <p className="msg">{error}</p>}
{check.data && (
<form id="reset-form" onSubmit={submit}>
<p>für <strong>{check.data.username}</strong></p>
<input name="username" type="hidden" value={check.data.username} autoComplete="username" />
<input name="pass1" type="password" placeholder="Neues Passwort" autoComplete="new-password" />{" "}
<input name="pass2" type="password" placeholder="Passwort wiederholen" autoComplete="new-password" />{" "}
<button type="submit" disabled={busy}>Passwort setzen</button>
</form>
)}
<p className="msg" id="reset-msg">{msg}</p>
</section>
);
}