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:
28
src/App.css
28
src/App.css
@@ -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;
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user