v3: added password change/reset feature

This commit is contained in:
khannurien
2026-04-06 16:30:00 +00:00
parent 3b6980a8fc
commit 20b9bfe7b4
26 changed files with 1268 additions and 236 deletions

165
src/pages/ResetPassword.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
type State =
| { status: "idle" }
| { status: "submitting" }
| { status: "done" }
| { status: "error"; error: string };
export function ResetPassword() {
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get("token") ?? "";
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [state, setState] = useState<State>({ status: "idle" });
const mismatch = confirm.length > 0 && newPassword !== confirm;
const tooShort = newPassword.length > 0 &&
newPassword.length < VALIDATION.PASSWORD_MIN;
if (!token) {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Invalid link</Trans>
</h1>
<p>
<Trans>This reset link is missing or malformed.</Trans>
</p>
<Link
to="/login"
className="btn-primary"
style={{ marginTop: "1rem", display: "inline-block" }}
>
<Trans>Back to login</Trans>
</Link>
</div>
</PageShell>
);
}
if (state.status === "done") {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Password updated</Trans>
</h1>
<p style={{ marginBottom: "1rem" }}>
<Trans>Your password has been changed. You can now log in.</Trans>
</p>
<button
type="button"
className="btn-primary"
onClick={() => navigate("/login")}
>
<Trans>Go to login</Trans>
</button>
</div>
</PageShell>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (mismatch || tooShort || !newPassword) return;
setState({ status: "submitting" });
try {
const res = await fetch(`${API_URL}/api/users/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword }),
});
const body = await res.json();
if (body.success) {
setState({ status: "done" });
} else {
setState({
status: "error",
error: body.error?.message ?? t`Unknown error`,
});
}
} catch {
setState({ status: "error", error: t`Could not connect to server` });
}
};
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Set new password</Trans>
</h1>
{state.status === "error" && (
<ErrorCard title={t`Reset failed`} message={state.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<input
type="password"
placeholder={t`New password`}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
required
autoFocus
disabled={state.status === "submitting"}
/>
{tooShort && (
<span className="auth-field-hint auth-field-hint--error">
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
</span>
)}
</div>
<div className="form-group">
<input
type="password"
placeholder={t`Confirm new password`}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
disabled={state.status === "submitting"}
/>
{mismatch && (
<span className="auth-field-hint auth-field-hint--error">
<Trans>Passwords do not match</Trans>
</span>
)}
</div>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting" || mismatch || tooShort ||
!newPassword || !confirm}
>
{state.status === "submitting"
? <Trans>Saving…</Trans>
: <Trans>Set new password</Trans>}
</button>
</form>
<p className="auth-card-footer">
<Link to="/login">
<Trans>Back to login</Trans>
</Link>
</p>
</div>
</PageShell>
);
}