From 106d44d384716ad9ceed105ce3dedd24cc0aaafc Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 23:49:55 -0400 Subject: [PATCH] Step 10K.9: backup and restore screens Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 + apps/web/src/api/client.ts | 7 +- apps/web/src/api/device.ts | 20 +++ apps/web/src/screens/BackupSettings.tsx | 184 ++++++++++++++++++++++++ apps/web/src/screens/Settings.tsx | 3 + apps/web/src/screens/StartScreen.tsx | 4 + apps/web/test/backup-ui.test.tsx | 124 ++++++++++++++++ memory-bank/progress.md | 2 +- 8 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/api/device.ts create mode 100644 apps/web/src/screens/BackupSettings.tsx create mode 100644 apps/web/test/backup-ui.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 551f92a..e1ad2b4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1097,3 +1097,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Endpoints:** `GET` says whether a code exists; `POST` makes a new one, shown once, and the old one stops working. The server never returns an existing code. - **Errors:** a wrong code is a generic `409 restore-failed` (no hint whether it was close); too many failures are `429 rate-limited`. 409 rather than 401, because the client treats 401 as "signed out". - **Rate limit:** 5 failed attempts per 15 minutes, counted per IP and per device id (the client sends `x-device-id`), in memory per server process, which is enough for the single-instance base game (Phase 13 can move it to Postgres). + +## Step 10K.9 + +- **The code is shown once, when made**, like the server promises: reopening "Back up company" with a code on file offers to generate a new one (with the warning) rather than showing the old code, which the server no longer knows. +- **Restore from the start screen never warns** (there's no company yet); in Settings it warns only when the current company is past the tutorial, as the CR asks. +- **Success replaces the cached player state with the restored one** and refreshes boards, then goes to HQ; the server has already switched the device's session cookie. +- **The device id is a random UUID in local storage**, sent only with restore requests for rate limiting; it isn't tied to the player. diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c43daae..4c47341 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -6,6 +6,7 @@ import { type RouteRequest, type RouteResponse, } from "@deadhead/schema"; +import { deviceId } from "./device"; /** The server answered with its documented error format. */ export class ApiError extends Error { @@ -74,7 +75,11 @@ export function createApiClient(options: ApiClientOptions = {}): ApiClient { const response = await doFetch(baseUrl + url, { method: route.method, credentials: "include", - headers: body ? { "content-type": "application/json" } : {}, + headers: { + ...(body ? { "content-type": "application/json" } : {}), + // Restore attempts are rate-limited per device (CR-011). + ...(name === "restoreCompany" ? { "x-device-id": deviceId() } : {}), + }, body, }); diff --git a/apps/web/src/api/device.ts b/apps/web/src/api/device.ts new file mode 100644 index 0000000..85d08e2 --- /dev/null +++ b/apps/web/src/api/device.ts @@ -0,0 +1,20 @@ +const DEVICE_KEY = "deadhead:device-id"; +let fallback: string | null = null; + +/** + * A random id for this device, kept in local storage: the server rate-limits + * restore attempts per device (decision 0.5.72). Without storage it lasts for + * the page's lifetime. + */ +export function deviceId(): string { + try { + const saved = localStorage.getItem(DEVICE_KEY); + if (saved) return saved; + const id = crypto.randomUUID(); + localStorage.setItem(DEVICE_KEY, id); + return id; + } catch { + fallback ??= Math.random().toString(36).slice(2) + Date.now().toString(36); + return fallback; + } +} diff --git a/apps/web/src/screens/BackupSettings.tsx b/apps/web/src/screens/BackupSettings.tsx new file mode 100644 index 0000000..dcd6b29 --- /dev/null +++ b/apps/web/src/screens/BackupSettings.tsx @@ -0,0 +1,184 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { useApi } from "../api/context"; +import { errorMessage } from "../state/actions"; +import { PLAYER_STATE_KEY } from "../state/player"; +import { Button, Sheet } from "../ui"; + +const BACKUP_KEY = ["backup-code"] as const; + +/** "Back up company": make a code (shown once), or replace the existing one (decision 0.5.72). */ +export function BackupCompany() { + const api = useApi(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const status = useQuery({ + queryKey: BACKUP_KEY, + queryFn: () => api.call("getBackupCode", {}), + enabled: open, + }); + const create = useMutation({ + mutationFn: () => api.call("createBackupCode", {}), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: BACKUP_KEY }), + }); + const code = create.data?.code; + + const close = () => { + setOpen(false); + setCopied(false); + create.reset(); + }; + const copy = (text: string) => { + void navigator.clipboard + ?.writeText(text) + .then(() => setCopied(true)) + .catch(() => setCopied(false)); + }; + + let body; + if (code) { + body = ( + <> +

+ {code} +

+

+ Keep this somewhere safe. Anyone with this code can open your company. +

+ + + ); + } else if (status.isPending) { + body =

Checking…

; + } else if (status.data?.hasCode) { + body = ( + <> +

+ You already have a backup code. It is only shown when it is made, so if you lost it, make + a new one. +

+

+ Your old code will stop working as soon as you generate a new one. +

+ + + ); + } else { + body = ( + <> +

+ A backup code lets you open this company on any device, or get it back if this one is + lost. No email or password. +

+ + + ); + } + + return ( + <> + + {open && ( + + {body} + {(create.isError || status.isError) && ( +

+ {errorMessage(create.error ?? status.error)} +

+ )} + +
+ )} + + ); +} + +/** + * "Restore a company": enter a backup code to open that company on this + * device. `pastTutorial` warns that this device's own company is left behind. + */ +export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) { + const api = useApi(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [code, setCode] = useState(""); + const restore = useMutation({ + mutationFn: () => api.call("restoreCompany", { code }), + onSuccess: async ({ state }) => { + queryClient.setQueryData(PLAYER_STATE_KEY, state); + await queryClient.invalidateQueries({ queryKey: ["board"] }); + setOpen(false); + void navigate("/"); + }, + }); + + return ( + <> + + {open && ( + + {pastTutorial && ( +

+ This device's current company will be left behind. Back it up first if you want + to keep it. +

+ )} +
{ + e.preventDefault(); + if (code.trim()) restore.mutate(); + }} + > + + {restore.isError && ( +

+ {errorMessage(restore.error)} +

+ )} +
+ + +
+
+
+ )} + + ); +} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index af46c9f..8536796 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { resetMapView } from "../map/camera"; +import { BackupCompany, RestoreCompany } from "./BackupSettings"; import { CompanyName, NewCompany } from "./CompanySettings"; import { useLoadedPlayer } from "../state/actions"; import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences"; @@ -22,6 +23,8 @@ export function Settings() {
+ +
diff --git a/apps/web/src/screens/StartScreen.tsx b/apps/web/src/screens/StartScreen.tsx index 124d043..2ff3129 100644 --- a/apps/web/src/screens/StartScreen.tsx +++ b/apps/web/src/screens/StartScreen.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router"; import { useApi } from "../api/context"; import { PLAYER_STATE_KEY } from "../state/player"; import { Art, Button, Icon } from "../ui"; +import { RestoreCompany } from "./BackupSettings"; /** First launch: create a guest account (decision: guests first, accounts later). */ export function StartScreen() { @@ -58,6 +59,9 @@ export function StartScreen() { {start.isPending ? "Starting…" : "Start your company"} +
+ +
{start.isError && (

Could not start a company. Check your connection and try again. diff --git a/apps/web/test/backup-ui.test.tsx b/apps/web/test/backup-ui.test.tsx new file mode 100644 index 0000000..950761e --- /dev/null +++ b/apps/web/test/backup-ui.test.tsx @@ -0,0 +1,124 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../src/api/client"; +import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render"; + +const CODE = "ABCDE-FGHJK-MNPQR-STUVW"; +const owner = () => samplePlayerState({ tutorial: null }); +const restored = () => + samplePlayerState({ + tutorial: null, + company: { id: "c2", name: "Restored Freight", credits: 77_000, loanBalance: 0 }, + }); + +async function openRestore() { + await userEvent.click(await screen.findByRole("button", { name: "Restore a company" })); + return screen.getByRole("dialog", { name: "Restore a company" }); +} + +describe("backup and restore (Step 10K.9)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("shows a new code grouped, and Copy puts it on the clipboard", async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + renderApp( + fakeApi({ + getPlayerState: owner, + getBackupCode: () => ({ hasCode: false, createdAt: null }), + createBackupCode: () => ({ code: CODE, createdAt: 1 }), + }), + "/settings", + ); + await userEvent.click(await screen.findByRole("button", { name: "Back up company" })); + const sheet = screen.getByRole("dialog", { name: "Back up your company" }); + await userEvent.click( + await within(sheet).findByRole("button", { name: "Create a backup code" }), + ); + expect(await within(sheet).findByLabelText("Backup code")).toHaveTextContent(CODE); + expect(sheet).toHaveTextContent("Anyone with this code can open your company."); + await userEvent.click(within(sheet).getByRole("button", { name: "Copy" })); + expect(writeText).toHaveBeenCalledWith(CODE); + expect(await within(sheet).findByRole("button", { name: "Copied" })).toBeInTheDocument(); + }); + + it("warns about an existing code before replacing it", async () => { + renderApp( + fakeApi({ + getPlayerState: owner, + getBackupCode: () => ({ hasCode: true, createdAt: 1 }), + }), + "/settings", + ); + await userEvent.click(await screen.findByRole("button", { name: "Back up company" })); + const sheet = screen.getByRole("dialog", { name: "Back up your company" }); + expect( + await within(sheet).findByRole("button", { name: "Generate a new code" }), + ).toBeInTheDocument(); + expect(sheet).toHaveTextContent("Your old code will stop working"); + }); + + it("warns that a company past the tutorial is left behind, and not during the tutorial", async () => { + const { unmount } = renderApp(fakeApi({ getPlayerState: owner }), "/settings"); + expect(await openRestore()).toHaveTextContent("current company will be left behind"); + unmount(); + renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/settings"); + expect(await openRestore()).not.toHaveTextContent("left behind"); + }); + + it("restores and goes to HQ", async () => { + let done = false; + const api = fakeApi({ + getPlayerState: () => (done ? restored() : owner()), + restoreCompany: () => { + done = true; + return { playerId: "p2", state: restored() }; + }, + }); + renderApp(api, "/settings"); + const sheet = await openRestore(); + await userEvent.type(within(sheet).getByRole("textbox"), CODE.toLowerCase()); + await userEvent.click(within(sheet).getByRole("button", { name: "Restore" })); + await waitFor(() => + expect(api.call).toHaveBeenCalledWith("restoreCompany", { code: CODE.toLowerCase() }), + ); + expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); + expect(screen.getByLabelText("Credits")).toHaveTextContent("77,000 cr"); + }); + + it("shows a failed restore's error and stays put", async () => { + renderApp( + fakeApi({ + getPlayerState: owner, + restoreCompany: () => { + throw new ApiError(409, "restore-failed", "That code didn't open a company."); + }, + }), + "/settings", + ); + const sheet = await openRestore(); + await userEvent.type(within(sheet).getByRole("textbox"), "WRONG"); + await userEvent.click(within(sheet).getByRole("button", { name: "Restore" })); + expect(await within(sheet).findByRole("alert")).toHaveTextContent("didn't open a company"); + expect(screen.getByRole("dialog", { name: "Restore a company" })).toBeInTheDocument(); + }); + + it("offers restore on the start screen too", async () => { + let done = false; + renderApp( + fakeApi({ + getPlayerState: () => (done ? restored() : unauthorized()), + restoreCompany: () => { + done = true; + return { playerId: "p2", state: restored() }; + }, + }), + ); + const sheet = await openRestore(); + expect(sheet).not.toHaveTextContent("left behind"); + await userEvent.type(within(sheet).getByRole("textbox"), CODE); + await userEvent.click(within(sheet).getByRole("button", { name: "Restore" })); + expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index a596890..e8b14b2 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -260,7 +260,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0 - [x] **Step 10K.6 — Server: start a new company.** Route `newCompany` (`POST /api/company/new`, `{}` → player state) runs `newCompany` in `withPlayer` (company row lock): deletes claimed goals, accepted contracts, dispatches, boards, discovered locations, bays, pilots, ships (upgrades cascade) and the company, resets the player's tutorial fields with a new world seed, then calls `createStartingState`, now shared with guest creation. Arrival jobs for deleted flights find nothing. Test: `new-company.test.ts` (a mid-game owner ends up identical to a fresh guest, new seed, same player id; the old flight's job is a no-op). The backup-code part of the test is in 10K.8. - [x] **Step 10K.7 — Client: rename and new company flows.** `screens/CompanySettings.tsx`: the company name has "Rename", an in-place field checked as you type with the server's `companyNameSchema` (message under the field, Save disabled until valid; saves the trimmed name). "Start a new company" opens a confirmation explaining what is lost; "Start over" stays disabled until the exact company name is typed (case-sensitive), then calls `newCompany`, clears dismissed tips and goes to Contracts, where the intro card shows. Test: `company-settings.test.tsx`. - [x] **Step 10K.8 — Server: backup code and restore.** Migration `0008_cr011_backup_codes`: `players.backup_code_hash` (unique) and `backup_code_created_at`; table `player_logins` (extra sign-ins per player). `game/backup.ts`: 20-character codes from `23456789ABCDEFGHJKMNPQRSTUVWXYZ` in four groups of five (≈ 99 bits), SHA-256 of the normalized code stored, `RestoreLimiter` (5 failures per 15 min per IP and per device id). Routes: `getBackupCode` (GET, whether one exists), `createBackupCode` (POST, new code shown once, old one invalidated), `restoreCompany` (POST `/api/restore`, no session: a fresh guest sign-in for the device linked to the code's player; `409 restore-failed` for a bad code, `429 rate-limited`). Session lookup falls back to `player_logins`. Test: `backup.test.ts` (format, restore on a second device with shared state, regenerate invalidates, wrong code and rate limit, no plain code in the database, still works after a new company). -- [ ] Step 10K.9 — Client: backup and restore flows +- [x] **Step 10K.9 — Client: backup and restore flows.** `screens/BackupSettings.tsx`: "Back up company" (Settings) creates a code and shows it once, large and grouped, with Copy and "Keep this somewhere safe. Anyone with this code can open your company."; with a code already made it offers "Generate a new code" and warns the old one stops working. "Restore a company" (Settings, and under "Start your company" on the start screen) takes a code; past the tutorial it warns the device's company will be left behind; success loads the restored state and goes to HQ; failures show the server's message in place. The API client sends `x-device-id` (`api/device.ts`) on restore. Test: `backup-ui.test.tsx`. - [ ] Step 10K.10 — Client: About and debug info - [ ] Step 10K.11 — Client and server: developer tools (non-production only) - [ ] Step 10K.12 — Plan amendments and regression