v4: asking for a password reset link is a page, /reset-password — "Forgot password?" unfolded the request form on /login as a state of the page, which a reload or a link lost, while /reset-password, where the emailed link leads, answered without its token "Invalid link". The page now holds both steps: without a token it asks for the link, with one it sets the new password, and "Forgot password?" links to it. The login page's unfolding panel and its styles are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 22:24:13 +00:00
parent 9a215b0cc5
commit 71df0268e3
11 changed files with 322 additions and 381 deletions

View File

@@ -3316,34 +3316,6 @@ body.has-player .chat-fab {
margin: 0;
}
.auth-link-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-accent);
font-size: inherit;
font-family: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.auth-link-btn:hover {
opacity: 0.8;
}
.auth-reset-panel {
border-top: 1px solid var(--color-border-subtle);
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-reset-form {
margin: 0;
}
.auth-reset-sent {
font-size: var(--text-md);
text-align: center;

View File

@@ -14,9 +14,9 @@
* - `/authorize` lets another gerbeur instance sign the user in with their
* account here (`Authorize`), and `/federation/callback` finishes a sign-in
* here with an account on another (`FederationCallback`).
* - `/reset-password` sets a new password from an emailed link
* (`ResetPassword`), and `/unsubscribe` turns off a digest from its emailed
* link (`Unsubscribe`).
* - `/reset-password` asks for a reset link by email, and sets a new password
* from it, `?token=` (`ResetPassword`), and `/unsubscribe` turns off a digest
* from its emailed link (`Unsubscribe`).
* - `/notifications` (`Notifications`) and `/settings` (`Settings`), signed in
* only, and `/manage`, the site's tools, for `category:manage` or
* `federation:manage` (`Manage`).

View File

@@ -1,6 +1,7 @@
/**
* Renders the page a password reset email links to, where the user sets a new
* password.
* Renders the reset of a forgotten password, both its steps on one page: the
* request for a link by email, which "Forgot password?" on the login page
* leads to, and the new password, which the link leads to.
*
* @module
*/
@@ -26,15 +27,88 @@ interface Values {
confirm: string;
}
interface RequestValues {
email: string;
}
/**
* Renders `/reset-password?token=`, for anyone holding a reset link. It doesn't
* sign the user in: a success links to `/login`.
* Renders `/reset-password`, for anyone: the request for a reset link, or,
* with the link's `?token=`, the new password. It doesn't sign the user in: a
* success links to `/login`.
*/
export function ResetPassword(): React.JSX.Element {
useDocumentTitle(t`Reset password`);
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get("token") ?? "";
return token ? <NewPassword token={token} /> : <ResetRequest />;
}
/**
* Renders the form that asks the server to email a reset link, which leads
* back here with its token.
*/
function ResetRequest() {
const [sent, setSent] = useState(false);
const form = useApiForm<RequestValues>({ defaultValues: { email: "" } });
const onSubmit = form.submit(async ({ email }) => {
// The server answers 200 whether or not the address is known, so as not
// to reveal which ones are. An error here means it failed.
await expectOk(
await fetch(`${API_URL}/api/password-resets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
}),
);
setSent(true);
});
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Reset password</Trans>
</h1>
{sent
? (
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link
shortly.
</Trans>
</p>
)
: (
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form">
<FormError title={t`Request failed`} />
<TextField<RequestValues>
name="email"
type="email"
placeholder={t`Your email address`}
autoFocus
rules={{ required: true }}
/>
<SubmitButton pendingLabel={<Trans>Sending…</Trans>}>
<Trans>Send reset link</Trans>
</SubmitButton>
</form>
</FormProvider>
)}
<p className="auth-card-footer">
<Link to="/login">
<Trans>Back to login</Trans>
</Link>
</p>
</div>
</PageShell>
);
}
/** Renders the form that sets a new password with the link's `token`. */
function NewPassword({ token }: { token: string }) {
const navigate = useNavigate();
const [done, setDone] = useState(false);
const form = useApiForm<Values>({
@@ -56,28 +130,6 @@ export function ResetPassword(): React.JSX.Element {
setDone(true);
});
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 (done) {
return (
<PageShell centered>

View File

@@ -1,11 +1,10 @@
/**
* Renders the sign-in page, which also requests password reset links.
* Renders the sign-in page.
*
* @module
*/
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -15,7 +14,6 @@ import type { AuthResponse, LoginUserRequest } from "../../shared/user.ts";
import type { Wire } from "../../shared/api.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { expectOk } from "../utils/shared/api.ts";
import { FormError } from "../components/form/FormError.tsx";
@@ -32,13 +30,10 @@ interface LoginValues {
password: string;
}
interface ResetValues {
email: string;
}
/**
* Renders `/login`, for the signed out. A sign-in lands on the page `?next=`
* names, or `/`, and "Forgot password?" unfolds the reset request form below.
* names, or `/`, and "Forgot password?" leads to `/reset-password`, which asks
* for a reset link.
* Users of another instance sign in through {@link FederatedSignIn}, unless
* the instance takes none.
*/
@@ -47,8 +42,6 @@ export function UserLogin(): React.JSX.Element {
const navigate = useNavigate();
const [params] = useSearchParams();
const { login } = useAuth();
const [showReset, setShowReset] = useState(false);
const cardRef = useHeightTransition<HTMLDivElement>(showReset);
const form = useApiForm<LoginValues>({
defaultValues: { username: "", password: "" },
@@ -68,7 +61,7 @@ export function UserLogin(): React.JSX.Element {
return (
<PageShell centered>
<div className="auth-card" ref={cardRef}>
<div className="auth-card">
<h1 className="auth-card-title">
<Trans>Log in</Trans>
</h1>
@@ -95,17 +88,11 @@ export function UserLogin(): React.JSX.Element {
</FormProvider>
<p className="auth-card-footer">
<button
type="button"
className="auth-link-btn"
onClick={() => setShowReset((v) => !v)}
>
<Link to="/reset-password">
<Trans>Forgot password?</Trans>
</button>
</Link>
</p>
{showReset && <ResetRequestPanel />}
{FEDERATION_INCOMING && <FederatedSignIn />}
<p className="auth-card-footer">
@@ -115,57 +102,3 @@ export function UserLogin(): React.JSX.Element {
</PageShell>
);
}
/**
* Renders the form that asks the server to email a password reset link, which
* leads to `/reset-password`.
*/
function ResetRequestPanel() {
const [sent, setSent] = useState(false);
const form = useApiForm<ResetValues>({ defaultValues: { email: "" } });
const onSubmit = form.submit(async ({ email }) => {
// The server answers 200 whether or not the address is known, so as not
// to reveal which ones are. An error here means it failed.
await expectOk(
await fetch(`${API_URL}/api/password-resets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
}),
);
setSent(true);
});
if (sent) {
return (
<div className="auth-reset-panel animate-in">
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link shortly.
</Trans>
</p>
</div>
);
}
return (
<div className="auth-reset-panel animate-in">
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form auth-reset-form">
<FormError title={t`Request failed`} />
<TextField<ResetValues>
name="email"
type="email"
placeholder={t`Your email address`}
autoFocus
rules={{ required: true }}
/>
<SubmitButton pendingLabel={<Trans>Sending…</Trans>}>
<Trans>Send reset link</Trans>
</SubmitButton>
</form>
</FormProvider>
</div>
);
}

View File

@@ -17,16 +17,37 @@ describe("ResetPassword", () => {
return user;
}
test("no token: an invalid link", () => {
setup("");
expect(screen.getByRole("heading", { name: "Invalid link" }))
.toBeInTheDocument();
test("without a token, it asks for a link by email", async () => {
route("POST /api/password-resets", () => ({}));
const user = setup("");
await user.type(
screen.getByPlaceholderText("Your email address"),
" a@b.c {Enter}",
);
await screen.findByText(/you'll receive a reset link/);
expect(calls("POST /api/password-resets")[0].body).toEqual({
email: "a@b.c",
});
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute(
"href",
"/login",
);
});
test("a failed request is reported, not claimed as sent", async () => {
route("POST /api/password-resets", () => fail("Internal error", 500));
const user = setup("");
await user.type(
screen.getByPlaceholderText("Your email address"),
"a@b.c{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Request failed",
);
expect(screen.queryByText(/you'll receive a reset link/)).not
.toBeInTheDocument();
});
test("the two passwords must match", async () => {
const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough");

View File

@@ -97,36 +97,10 @@ describe("UserLogin", () => {
await screen.findByText("home page");
});
test("password reset request", async () => {
route("POST /api/password-resets", () => ({}));
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(
screen.getByPlaceholderText("Your email address"),
" a@b.c {Enter}",
);
await screen.findByText(/you'll receive a reset link/);
expect(calls("POST /api/password-resets")[0].body).toEqual({
email: "a@b.c",
});
});
test("a failed reset request is reported, not claimed as sent", async () => {
route(
"POST /api/password-resets",
() => fail("Internal error", 500),
);
const user = setup();
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
await user.type(
screen.getByPlaceholderText("Your email address"),
"a@b.c{Enter}",
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Request failed",
);
expect(screen.queryByText(/you'll receive a reset link/)).not
.toBeInTheDocument();
test("Forgot password? leads to the reset page", () => {
setup();
expect(screen.getByRole("link", { name: "Forgot password?" }))
.toHaveAttribute("href", "/reset-password");
});
test("a sign-in follows ?next= on this site, and only there", async () => {

View File

@@ -107,7 +107,6 @@
[data-style="brutalist"] .rich-content-thumbnail-btn,
[data-style="brutalist"] .user-menu-trigger,
[data-style="brutalist"] .user-menu-item,
[data-style="brutalist"] .auth-link-btn,
[data-style="brutalist"] .form-cancel,
[data-style="brutalist"] .form-label-action {
border: none !important;
@@ -118,8 +117,6 @@
[data-style="brutalist"] .user-menu-trigger:active,
[data-style="brutalist"] .user-menu-item:hover:not(:disabled),
[data-style="brutalist"] .user-menu-item:active,
[data-style="brutalist"] .auth-link-btn:hover:not(:disabled),
[data-style="brutalist"] .auth-link-btn:active,
[data-style="brutalist"] .form-cancel:hover:not(:disabled),
[data-style="brutalist"] .form-cancel:active,
[data-style="brutalist"] .form-label-action:hover:not(:disabled),

View File

@@ -218,7 +218,6 @@
[data-style="geocities"] .rich-content-thumbnail-btn,
[data-style="geocities"] .user-menu-trigger,
[data-style="geocities"] .user-menu-item,
[data-style="geocities"] .auth-link-btn,
[data-style="geocities"] .form-cancel,
[data-style="geocities"] .form-label-action,
[data-style="geocities"] .category-editor-btn {