generated from nholtware/service-template-react
feat: account page with change-password form
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ae7bc89a82
commit
edb7c294f1
5 changed files with 196 additions and 2 deletions
|
|
@ -16,8 +16,22 @@ export const register = (username: string, password: string) =>
|
||||||
call<{ user: User }>("POST", "/auth/register", { username, password }).then(
|
call<{ user: User }>("POST", "/auth/register", { username, password }).then(
|
||||||
(r) => r.user,
|
(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");
|
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". */
|
/** Root loader: who am I? A failure just means "not signed in". */
|
||||||
export const authLoader = () => fetchMe().catch(() => null);
|
export const authLoader = () => fetchMe().catch(() => null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { RouteObject } from "react-router";
|
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 { Build, buildLoader } from "./routes/Build";
|
||||||
|
import { Account } from "./routes/Account";
|
||||||
import { BuildList, buildListLoader } from "./routes/BuildList";
|
import { BuildList, buildListLoader } from "./routes/BuildList";
|
||||||
import { ErrorPage } from "./routes/ErrorPage";
|
import { ErrorPage } from "./routes/ErrorPage";
|
||||||
import { Layout } from "./routes/Layout";
|
import { Layout } from "./routes/Layout";
|
||||||
|
|
@ -20,6 +21,11 @@ export const routes: RouteObject[] = [
|
||||||
{ index: true, element: <Library />, loader: libraryLoader },
|
{ index: true, element: <Library />, loader: libraryLoader },
|
||||||
{ path: "play/:id", element: <Play />, loader: playLoader },
|
{ path: "play/:id", element: <Play />, loader: playLoader },
|
||||||
{ path: "login", element: <Login /> },
|
{ path: "login", element: <Login /> },
|
||||||
|
{
|
||||||
|
path: "account",
|
||||||
|
element: <Account />,
|
||||||
|
loader: ({ request }) => requireUser(request),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "build",
|
path: "build",
|
||||||
element: <BuildList />,
|
element: <BuildList />,
|
||||||
|
|
|
||||||
87
src/routes/Account.tsx
Normal file
87
src/routes/Account.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -34,7 +34,9 @@ export function Layout() {
|
||||||
<span className="who">
|
<span className="who">
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<span data-testid="whoami">{user.username}</span>{" "}
|
<Link to="/account" data-testid="whoami">
|
||||||
|
{user.username}
|
||||||
|
</Link>{" "}
|
||||||
<button className="link" onClick={() => void signOut()}>
|
<button className="link" onClick={() => void signOut()}>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue