diff --git a/src/app.test.tsx b/src/app.test.tsx index 871d930..17b6cf3 100644 --- a/src/app.test.tsx +++ b/src/app.test.tsx @@ -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 () => { diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..9053377 --- /dev/null +++ b/src/lib/auth.ts @@ -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("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( + request: Request, + load: () => Promise, +): Promise { + 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"; +} diff --git a/src/lib/stories.ts b/src/lib/stories.ts index 06b990a..86a02c8 100644 --- a/src/lib/stories.ts +++ b/src/lib/stories.ts @@ -47,7 +47,7 @@ export class ApiError extends Error { } } -async function call( +export async function call( 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) => diff --git a/src/routes.tsx b/src/routes.tsx index 2006760..96bcf29 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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: , + loader: authLoader, errorElement: , children: [ { index: true, element: , loader: libraryLoader }, { path: "play/:id", element: , loader: playLoader }, - { path: "build", element: , loader: buildListLoader }, - { path: "build/:id", element: , loader: buildLoader }, + { path: "login", element: }, + { + path: "build", + element: , + loader: ({ request }) => withLogin(request, buildListLoader), + }, + { + path: "build/:id", + element: , + loader: (args) => withLogin(args.request, () => buildLoader(args)), + }, { path: "status", element: , loader: statusLoader }, ], }, diff --git a/src/routes/Build.tsx b/src/routes/Build.tsx index 4e2b84e..62f8861 100644 --- a/src/routes/Build.tsx +++ b/src/routes/Build.tsx @@ -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() { diff --git a/src/routes/Layout.tsx b/src/routes/Layout.tsx index ab27d83..3ab33ca 100644 --- a/src/routes/Layout.tsx +++ b/src/routes/Layout.tsx @@ -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 ( <>
@@ -13,6 +31,18 @@ export function Layout() { Build Status + + {user ? ( + <> + {user.username}{" "} + + + ) : ( + Log in + )} +
diff --git a/src/routes/Login.tsx b/src/routes/Login.tsx new file mode 100644 index 0000000..2761455 --- /dev/null +++ b/src/routes/Login.tsx @@ -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(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 ( +
+

{isLogin ? "Log in" : "Create an account"}

+
+ + + {error &&

{error}

} + {" "} + +
+
+ ); +} diff --git a/src/routes/auth.test.tsx b/src/routes/auth.test.tsx new file mode 100644 index 0000000..5fbd5f2 --- /dev/null +++ b/src/routes/auth.test.tsx @@ -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; +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) { + 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(); + 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"); + } + }); +}); diff --git a/src/styles.css b/src/styles.css index 78490ca..ff1ec70 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; +}