feat: account page with change-password form
All checks were successful
/ checks (push) Successful in 42s
/ deploy (push) Successful in 4s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nholtware 2026-11-05 13:47:38 -06:00
parent ae7bc89a82
commit edb7c294f1
5 changed files with 196 additions and 2 deletions

View file

@ -16,8 +16,22 @@ export const register = (username: string, password: string) =>
call<{ user: User }>("POST", "/auth/register", { username, password }).then(
(r) => r.user,
);
export const changePassword = (currentPassword: string, newPassword: string) =>
call<void>("POST", "/auth/password", { currentPassword, newPassword });
export const logout = () => call<void>("POST", "/auth/logout");
/** Loader for pages that need a signed-in user: /auth/me says null -> /login. */
export async function requireUser(request: Request): Promise<User> {
const user = await fetchMe();
if (!user) {
const url = new URL(request.url);
throw redirect(
`/login?next=${encodeURIComponent(url.pathname + url.search)}`,
);
}
return user;
}
/** Root loader: who am I? A failure just means "not signed in". */
export const authLoader = () => fetchMe().catch(() => null);

View file

@ -1,6 +1,7 @@
import type { RouteObject } from "react-router";
import { authLoader, withLogin } from "./lib/auth";
import { authLoader, requireUser, withLogin } from "./lib/auth";
import { Build, buildLoader } from "./routes/Build";
import { Account } from "./routes/Account";
import { BuildList, buildListLoader } from "./routes/BuildList";
import { ErrorPage } from "./routes/ErrorPage";
import { Layout } from "./routes/Layout";
@ -20,6 +21,11 @@ export const routes: RouteObject[] = [
{ index: true, element: <Library />, loader: libraryLoader },
{ path: "play/:id", element: <Play />, loader: playLoader },
{ path: "login", element: <Login /> },
{
path: "account",
element: <Account />,
loader: ({ request }) => requireUser(request),
},
{
path: "build",
element: <BuildList />,

87
src/routes/Account.tsx Normal file
View file

@ -0,0 +1,87 @@
import { useState, type FormEvent } from "react";
import { useLoaderData } from "react-router";
import { changePassword, type User } from "../lib/auth";
export function Account() {
const user = useLoaderData() as User;
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setDone(false);
if (next !== confirm) {
setError("The new passwords do not match");
return;
}
setBusy(true);
setError(null);
try {
await changePassword(current, next);
setCurrent("");
setNext("");
setConfirm("");
setDone(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setBusy(false);
}
}
return (
<section className="editor auth">
<h1>Account</h1>
<p>
Signed in as <strong>{user.username}</strong>.
</p>
<h2>Change password</h2>
<form onSubmit={submit}>
<label>
Current password
<input
type="password"
autoComplete="current-password"
required
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</label>
<label>
New password
<input
type="password"
autoComplete="new-password"
required
minLength={8}
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</label>
<label>
Confirm new password
<input
type="password"
autoComplete="new-password"
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
{done && (
<p role="status">
Password changed. Your other devices have been signed out.
</p>
)}
<button type="submit" disabled={busy}>
Change password
</button>
</form>
</section>
);
}

View file

@ -34,7 +34,9 @@ export function Layout() {
<span className="who">
{user ? (
<>
<span data-testid="whoami">{user.username}</span>{" "}
<Link to="/account" data-testid="whoami">
{user.username}
</Link>{" "}
<button className="link" onClick={() => void signOut()}>
Log out
</button>

View file

@ -199,3 +199,88 @@ describe("safeNext", () => {
}
});
});
describe("account page", () => {
const me = { user: { id: "1", username: "alice" } };
it("redirects to login when signed out", async () => {
mockApi({ "GET /auth/me": () => json({ user: null }) });
const router = renderAt("/account");
expect(
await screen.findByRole("heading", { name: "Log in" }),
).toBeInTheDocument();
expect(router.state.location.search).toBe("?next=%2Faccount");
});
it("changes the password and clears the form", async () => {
const user = userEvent.setup();
let body: unknown;
mockApi({
"GET /auth/me": () => json(me),
"POST /auth/password": (init) => {
body = JSON.parse(String(init?.body));
return new Response(null, { status: 204 });
},
});
renderAt("/account");
await user.type(
await screen.findByLabelText("Current password"),
"old password",
);
await user.type(screen.getByLabelText("New password"), "new passphrase");
await user.type(
screen.getByLabelText("Confirm new password"),
"new passphrase",
);
await user.click(screen.getByRole("button", { name: "Change password" }));
expect(await screen.findByRole("status")).toHaveTextContent(
"Password changed",
);
expect(body).toEqual({
currentPassword: "old password",
newPassword: "new passphrase",
});
expect(screen.getByLabelText("Current password")).toHaveValue("");
});
it("does not call the API when the confirmation differs", async () => {
const user = userEvent.setup();
const calls = mockApi({ "GET /auth/me": () => json(me) });
renderAt("/account");
await user.type(
await screen.findByLabelText("Current password"),
"old password",
);
await user.type(screen.getByLabelText("New password"), "new passphrase");
await user.type(
screen.getByLabelText("Confirm new password"),
"different one",
);
await user.click(screen.getByRole("button", { name: "Change password" }));
expect(await screen.findByRole("alert")).toHaveTextContent("do not match");
expect(calls).not.toContain("POST /auth/password");
});
it("shows the server's message for a wrong current password", async () => {
const user = userEvent.setup();
mockApi({
"GET /auth/me": () => json(me),
"POST /auth/password": () =>
json({ error: "current password is incorrect" }, 403),
});
renderAt("/account");
await user.type(
await screen.findByLabelText("Current password"),
"wrong wrong",
);
await user.type(screen.getByLabelText("New password"), "new passphrase");
await user.type(
screen.getByLabelText("Confirm new password"),
"new passphrase",
);
await user.click(screen.getByRole("button", { name: "Change password" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"current password is incorrect",
);
});
});