From edb7c294f12336bc94f0bcc93c0492e260204230 Mon Sep 17 00:00:00 2001 From: nholtware Date: Thu, 5 Nov 2026 13:47:38 -0600 Subject: [PATCH] feat: account page with change-password form Co-Authored-By: Claude Sonnet 5.5 --- src/lib/auth.ts | 14 +++++++ src/routes.tsx | 8 +++- src/routes/Account.tsx | 87 ++++++++++++++++++++++++++++++++++++++++ src/routes/Layout.tsx | 4 +- src/routes/auth.test.tsx | 85 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/routes/Account.tsx diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9053377..51c6712 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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("POST", "/auth/password", { currentPassword, newPassword }); export const logout = () => call("POST", "/auth/logout"); +/** Loader for pages that need a signed-in user: /auth/me says null -> /login. */ +export async function requireUser(request: Request): Promise { + 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); diff --git a/src/routes.tsx b/src/routes.tsx index 96bcf29..01285b9 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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: , loader: libraryLoader }, { path: "play/:id", element: , loader: playLoader }, { path: "login", element: }, + { + path: "account", + element: , + loader: ({ request }) => requireUser(request), + }, { path: "build", element: , diff --git a/src/routes/Account.tsx b/src/routes/Account.tsx new file mode 100644 index 0000000..2a77aa5 --- /dev/null +++ b/src/routes/Account.tsx @@ -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(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 ( +
+

Account

+

+ Signed in as {user.username}. +

+

Change password

+
+ + + + {error &&

{error}

} + {done && ( +

+ Password changed. Your other devices have been signed out. +

+ )} + +
+
+ ); +} diff --git a/src/routes/Layout.tsx b/src/routes/Layout.tsx index 3ab33ca..9176167 100644 --- a/src/routes/Layout.tsx +++ b/src/routes/Layout.tsx @@ -34,7 +34,9 @@ export function Layout() { {user ? ( <> - {user.username}{" "} + + {user.username} + {" "} diff --git a/src/routes/auth.test.tsx b/src/routes/auth.test.tsx index 5fbd5f2..7f89e00 100644 --- a/src/routes/auth.test.tsx +++ b/src/routes/auth.test.tsx @@ -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", + ); + }); +});