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(
|
||||
(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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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">
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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