feat: passport-local authentication, story ownership
All checks were successful
/ checks (push) Successful in 41s
/ 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:33:51 -06:00
parent ef23fdd187
commit ae7bc89a82
9 changed files with 395 additions and 11 deletions

View file

@ -79,10 +79,10 @@ describe("app", () => {
expect(await screen.findByText(/GET \/readyz/)).toBeInTheDocument();
expect(screen.getByText(/"status": "ready"/)).toBeInTheDocument();
expect(screen.getByText(/"service": "svc"/)).toBeInTheDocument();
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
"/api/",
"/api/readyz",
]);
// Layout also asks /auth/me who is signed in; that's not what's under test.
expect(
fetchMock.mock.calls.map((c) => c[0]).filter((u) => u !== "/api/auth/me"),
).toEqual(["/api/", "/api/readyz"]);
});
it("status shows the error page when the backend is unreachable", async () => {

45
src/lib/auth.ts Normal file
View file

@ -0,0 +1,45 @@
import { redirect } from "react-router";
import { ApiError, call } from "./stories";
export interface User {
id: string;
username: string;
}
export const fetchMe = () =>
call<{ user: User | null }>("GET", "/auth/me").then((r) => r.user);
export const login = (username: string, password: string) =>
call<{ user: User }>("POST", "/auth/login", { username, password }).then(
(r) => r.user,
);
export const register = (username: string, password: string) =>
call<{ user: User }>("POST", "/auth/register", { username, password }).then(
(r) => r.user,
);
export const logout = () => call<void>("POST", "/auth/logout");
/** Root loader: who am I? A failure just means "not signed in". */
export const authLoader = () => fetchMe().catch(() => null);
/** Run a route loader; a 401 sends the visitor to /login and back again. */
export async function withLogin<T>(
request: Request,
load: () => Promise<T>,
): Promise<T> {
try {
return await load();
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
const url = new URL(request.url);
throw redirect(
`/login?next=${encodeURIComponent(url.pathname + url.search)}`,
);
}
throw e;
}
}
/** Only same-site paths are valid post-login destinations (no open redirect). */
export function safeNext(next: string | null): string {
return next && /^\/(?![/\\])/.test(next) ? next : "/build";
}

View file

@ -47,7 +47,7 @@ export class ApiError extends Error {
}
}
async function call<T>(
export async function call<T>(
method: string,
path: string,
body?: unknown,
@ -85,7 +85,10 @@ export const listStories = (mine: boolean) =>
`/stories${mine ? "?mine=1" : ""}`,
).then((r) => r.stories);
export const getStory = (id: string) =>
call<{ story: Story; issues: Issue[] }>("GET", `/stories/${id}`);
call<{ story: Story; issues: Issue[]; ownedByMe: boolean }>(
"GET",
`/stories/${id}`,
);
export const createStory = (input: StoryInput) =>
call<{ story: Story; issues: Issue[] }>("POST", "/stories", input);
export const saveStory = (id: string, input: StoryInput) =>

View file

@ -1,9 +1,11 @@
import type { RouteObject } from "react-router";
import { authLoader, withLogin } from "./lib/auth";
import { Build, buildLoader } from "./routes/Build";
import { BuildList, buildListLoader } from "./routes/BuildList";
import { ErrorPage } from "./routes/ErrorPage";
import { Layout } from "./routes/Layout";
import { Library, libraryLoader } from "./routes/Library";
import { Login } from "./routes/Login";
import { Play, playLoader } from "./routes/Play";
import { Status, statusLoader } from "./routes/Status";
@ -12,12 +14,22 @@ export const routes: RouteObject[] = [
{
path: "/",
element: <Layout />,
loader: authLoader,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Library />, loader: libraryLoader },
{ path: "play/:id", element: <Play />, loader: playLoader },
{ path: "build", element: <BuildList />, loader: buildListLoader },
{ path: "build/:id", element: <Build />, loader: buildLoader },
{ path: "login", element: <Login /> },
{
path: "build",
element: <BuildList />,
loader: ({ request }) => withLogin(request, buildListLoader),
},
{
path: "build/:id",
element: <Build />,
loader: (args) => withLogin(args.request, () => buildLoader(args)),
},
{ path: "status", element: <Status />, loader: statusLoader },
],
},

View file

@ -1,5 +1,10 @@
import { useMemo, useState } from "react";
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
import {
Link,
redirect,
useLoaderData,
type LoaderFunctionArgs,
} from "react-router";
import { downloadJson, exportStory } from "../lib/exchange";
import {
analyze,
@ -20,7 +25,11 @@ import {
} from "../lib/stories";
export async function buildLoader({ params }: LoaderFunctionArgs) {
return (await getStory(params.id!)).story;
const { story, ownedByMe } = await getStory(params.id!);
// Someone else's published story: send them to the player, not an editor
// whose saves would 403.
if (!ownedByMe) throw redirect(`/play/${story.id}`);
return story;
}
export function Build() {

View file

@ -1,7 +1,25 @@
import { NavLink, Outlet } from "react-router";
import {
Link,
NavLink,
Outlet,
useLoaderData,
useNavigate,
useRevalidator,
} from "react-router";
import { logout, type User } from "../lib/auth";
import { getConfig } from "../lib/config";
export function Layout() {
const user = useLoaderData() as User | null;
const navigate = useNavigate();
const { revalidate } = useRevalidator();
async function signOut() {
await logout().catch(() => {});
navigate("/");
await revalidate();
}
return (
<>
<header>
@ -13,6 +31,18 @@ export function Layout() {
<NavLink to="/build">Build</NavLink>
<NavLink to="/status">Status</NavLink>
</nav>
<span className="who">
{user ? (
<>
<span data-testid="whoami">{user.username}</span>{" "}
<button className="link" onClick={() => void signOut()}>
Log out
</button>
</>
) : (
<Link to="/login">Log in</Link>
)}
</span>
</header>
<main>
<Outlet />

72
src/routes/Login.tsx Normal file
View file

@ -0,0 +1,72 @@
import { useState, type FormEvent } from "react";
import { useNavigate, useRevalidator, useSearchParams } from "react-router";
import { login, register, safeNext } from "../lib/auth";
export function Login() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { revalidate } = useRevalidator();
const [mode, setMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await (mode === "login" ? login : register)(username, password);
await revalidate();
navigate(safeNext(params.get("next")), { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setBusy(false);
}
}
const isLogin = mode === "login";
return (
<section className="editor auth">
<h1>{isLogin ? "Log in" : "Create an account"}</h1>
<form onSubmit={submit}>
<label>
Username
<input
value={username}
autoComplete="username"
required
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label>
Password
<input
type="password"
value={password}
autoComplete={isLogin ? "current-password" : "new-password"}
required
minLength={isLogin ? undefined : 8}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={busy}>
{isLogin ? "Log in" : "Sign up"}
</button>{" "}
<button
type="button"
className="link"
onClick={() => {
setMode(isLogin ? "register" : "login");
setError(null);
}}
>
{isLogin ? "Need an account? Sign up" : "Have an account? Log in"}
</button>
</form>
</section>
);
}

201
src/routes/auth.test.tsx Normal file
View file

@ -0,0 +1,201 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { safeNext } from "../lib/auth";
import { routes } from "../routes";
vi.mock("@unleash/proxy-client-react", () => ({
FlagProvider: ({ children }: { children: React.ReactNode }) => children,
useFlag: () => false,
useVariant: () => ({ name: "disabled", enabled: false }),
}));
type Handler = (init?: RequestInit) => Response | Promise<Response>;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status });
/** fetch stub dispatching on "METHOD /path"; unknown calls fail the test loudly. */
function mockApi(handlers: Record<string, Handler>) {
const calls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${url.replace(/^\/api/, "")}`;
calls.push(key);
const h = handlers[key];
if (!h) throw new Error(`unexpected request: ${key}`);
return h(init);
}),
);
return calls;
}
function renderAt(path: string) {
const router = createMemoryRouter(routes, { initialEntries: [path] });
render(<RouterProvider router={router} />);
return router;
}
beforeEach(() => {
window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "t" };
});
describe("auth UI", () => {
it("shows a Log in link when signed out and the username when signed in", async () => {
mockApi({
"GET /auth/me": () => json({ user: null }),
"GET /stories": () => json({ stories: [] }),
});
renderAt("/");
expect(
await screen.findByRole("link", { name: "Log in" }),
).toBeInTheDocument();
mockApi({
"GET /auth/me": () => json({ user: { id: "1", username: "alice" } }),
"GET /stories": () => json({ stories: [] }),
});
renderAt("/");
expect(await screen.findByTestId("whoami")).toHaveTextContent("alice");
});
it("redirects /build to /login with next= when the API says 401", async () => {
mockApi({
"GET /auth/me": () => json({ user: null }),
"GET /stories?mine=1": () =>
json({ error: "authentication required" }, 401),
});
const router = renderAt("/build");
expect(
await screen.findByRole("heading", { name: "Log in" }),
).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/login");
expect(router.state.location.search).toBe("?next=%2Fbuild");
});
it("logs in, returns to next, and shows server errors for bad credentials", async () => {
const user = userEvent.setup();
let signedIn = false;
mockApi({
"GET /auth/me": () =>
json({ user: signedIn ? { id: "1", username: "alice" } : null }),
"GET /stories?mine=1": () => json({ stories: [] }),
"POST /auth/login": (init) => {
const body = JSON.parse(String(init?.body));
if (body.password !== "correct horse") {
return json({ error: "invalid username or password" }, 401);
}
signedIn = true;
return json({ user: { id: "1", username: "alice" } });
},
});
const router = renderAt("/login?next=%2Fbuild");
await user.type(await screen.findByLabelText("Username"), "alice");
await user.type(screen.getByLabelText("Password"), "wrong");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"invalid username or password",
);
await user.clear(screen.getByLabelText("Password"));
await user.type(screen.getByLabelText("Password"), "correct horse");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(
await screen.findByRole("heading", { name: "Your stories" }),
).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/build");
expect(screen.getByTestId("whoami")).toHaveTextContent("alice");
});
it("registers a new account", async () => {
const user = userEvent.setup();
const calls = mockApi({
"GET /auth/me": () => json({ user: null }),
"GET /stories?mine=1": () => json({ stories: [] }),
"POST /auth/register": (init) => {
expect(JSON.parse(String(init?.body))).toEqual({
username: "bob",
password: "correct horse",
});
return json({ user: { id: "2", username: "bob" } }, 201);
},
});
renderAt("/login");
await user.click(await screen.findByRole("button", { name: /Sign up/ }));
expect(
screen.getByRole("heading", { name: "Create an account" }),
).toBeInTheDocument();
await user.type(screen.getByLabelText("Username"), "bob");
await user.type(screen.getByLabelText("Password"), "correct horse");
await user.click(screen.getByRole("button", { name: "Sign up" }));
await waitFor(() => expect(calls).toContain("POST /auth/register"));
expect(
await screen.findByRole("heading", { name: "Your stories" }),
).toBeInTheDocument();
});
it("logs out and returns to the library", async () => {
const user = userEvent.setup();
let signedIn = true;
mockApi({
"GET /auth/me": () =>
json({ user: signedIn ? { id: "1", username: "alice" } : null }),
"GET /stories": () => json({ stories: [] }),
"POST /auth/logout": () => {
signedIn = false;
return new Response(null, { status: 204 });
},
});
renderAt("/");
await user.click(await screen.findByRole("button", { name: "Log out" }));
expect(
await screen.findByRole("link", { name: "Log in" }),
).toBeInTheDocument();
expect(screen.queryByTestId("whoami")).not.toBeInTheDocument();
});
it("sends editors of someone else's published story to the player", async () => {
mockApi({
"GET /auth/me": () => json({ user: { id: "2", username: "bob" } }),
"GET /stories/s1": () =>
json({
story: {
id: "s1",
title: "Cave",
description: "",
published: true,
updatedAt: "",
doc: {
start: "a",
nodes: {
a: { title: "A", text: "hi", ending: "win", choices: [] },
},
},
},
issues: [],
ownedByMe: false,
}),
});
const router = renderAt("/build/s1");
expect(await screen.findByText("hi")).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/play/s1");
});
});
describe("safeNext", () => {
it("allows same-site paths and rejects anything that could leave the site", () => {
expect(safeNext("/build/abc?x=1")).toBe("/build/abc?x=1");
for (const bad of [
null,
"",
"build",
"//evil.com",
"/\\evil.com",
"https://evil.com",
"javascript:alert(1)",
]) {
expect(safeNext(bad)).toBe("/build");
}
});
});

View file

@ -164,3 +164,15 @@ button.danger {
.choice-row select {
margin-top: 0 !important;
}
header .who {
margin-left: auto;
}
header .who button.link {
color: #f5e9d3;
}
.auth {
max-width: 24rem;
}
[role="alert"] {
color: #a31d1d;
}