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>
);
}

View File

@@ -16,21 +16,29 @@ import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
type UserLoginState =
type LoginState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
type ResetState =
| { status: "idle" }
| { status: "submitting" }
| { status: "sent" }
| { status: "error"; error: string };
export function UserLogin() {
const navigate = useNavigate();
const { login } = useAuth();
const [state, setState] = useState<UserLoginState>({ status: "idle" });
const [loginState, setLoginState] = useState<LoginState>({ status: "idle" });
const [showReset, setShowReset] = useState(false);
const [resetEmail, setResetEmail] = useState("");
const [resetState, setResetState] = useState<ResetState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
setLoginState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username") as string;
@@ -49,10 +57,26 @@ export function UserLogin() {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setState({ status: "error", error: apiResponse.error.message });
setLoginState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });
setLoginState({ status: "error", error: friendlyFetchError(err) });
}
};
const handleResetRequest = async (e: React.FormEvent) => {
e.preventDefault();
if (!resetEmail.trim()) return;
setResetState({ status: "submitting" });
try {
await fetch(`${API_URL}/api/users/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: resetEmail.trim() }),
});
setResetState({ status: "sent" });
} catch {
setResetState({ status: "error", error: t`Could not connect to server` });
}
};
@@ -63,8 +87,8 @@ export function UserLogin() {
<Trans>Log in</Trans>
</h1>
{state.status === "error" && (
<ErrorCard title={t`Login failed`} message={state.error} />
{loginState.status === "error" && (
<ErrorCard title={t`Login failed`} message={loginState.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
@@ -73,7 +97,7 @@ export function UserLogin() {
type="text"
placeholder={t`Username`}
required
disabled={state.status === "submitting"}
disabled={loginState.status === "submitting"}
autoFocus
/>
<input
@@ -81,19 +105,81 @@ export function UserLogin() {
type="password"
placeholder={t`Password`}
required
disabled={state.status === "submitting"}
disabled={loginState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting"}
disabled={loginState.status === "submitting"}
>
{state.status === "submitting"
{loginState.status === "submitting"
? <Trans>Logging in</Trans>
: <Trans>Log in</Trans>}
</button>
</form>
<p className="auth-card-footer">
<button
type="button"
className="auth-link-btn"
onClick={() => {
setShowReset((v) => !v);
setResetState({ status: "idle" });
setResetEmail("");
}}
>
<Trans>Forgot password?</Trans>
</button>
</p>
{showReset && (
<div className="auth-reset-panel">
{resetState.status === "sent"
? (
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link
shortly.
</Trans>
</p>
)
: (
<>
{resetState.status === "error" && (
<ErrorCard
title={t`Request failed`}
message={resetState.error}
/>
)}
<form
onSubmit={handleResetRequest}
className="auth-form auth-reset-form"
>
<input
type="email"
placeholder={t`Your email address`}
value={resetEmail}
onChange={(e) => setResetEmail(e.target.value)}
required
autoFocus
disabled={resetState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={resetState.status === "submitting" ||
!resetEmail.trim()}
>
{resetState.status === "submitting"
? <Trans>Sending</Trans>
: <Trans>Send reset link</Trans>}
</button>
</form>
</>
)}
</div>
)}
<p className="auth-card-footer">
<Trans>This is a mirage.</Trans>
</p>

View File

@@ -52,6 +52,7 @@ import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { TextEditor } from "../components/TextEditor.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
function InviteButton() {
const { authFetch } = useAuth();
@@ -284,6 +285,7 @@ export function UserPublicProfile() {
const [tab, setTab] = useState<
"dumps" | "playlists" | "followed" | "invitees" | "settings"
>("dumps");
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const [followedState, setFollowedState] = useState<FollowedState>(null);
const [inviteTreeState, setInviteTreeState] = useState<InviteTreeState>(null);
@@ -1202,8 +1204,31 @@ export function UserPublicProfile() {
</section>
)}
{changePasswordOpen && (
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
)}
{tab === "settings" && isOwnProfile && (
<>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Account</Trans>
</h2>
<div className="profile-appearance-grid">
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Password</Trans>
</span>
<button
type="button"
className="btn btn--ghost"
onClick={() => setChangePasswordOpen(true)}
>
<Trans>Change password</Trans>
</button>
</div>
</div>
</section>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Appearance</Trans>