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

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>