Step 10K.9: backup and restore screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 23:49:55 -04:00
parent 69cc5ff3a7
commit 106d44d384
8 changed files with 349 additions and 2 deletions

View File

@ -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. - **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". - **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). - **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.

View File

@ -6,6 +6,7 @@ import {
type RouteRequest, type RouteRequest,
type RouteResponse, type RouteResponse,
} from "@deadhead/schema"; } from "@deadhead/schema";
import { deviceId } from "./device";
/** The server answered with its documented error format. */ /** The server answered with its documented error format. */
export class ApiError extends Error { export class ApiError extends Error {
@ -74,7 +75,11 @@ export function createApiClient(options: ApiClientOptions = {}): ApiClient {
const response = await doFetch(baseUrl + url, { const response = await doFetch(baseUrl + url, {
method: route.method, method: route.method,
credentials: "include", 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, body,
}); });

View File

@ -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;
}
}

View File

@ -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 = (
<>
<p
aria-label="Backup code"
className="mt-4 rounded-xl bg-space-950/80 p-4 text-center font-mono text-2xl font-bold tracking-widest text-amber-200 ring-1 ring-amber-400/40 select-all"
>
{code}
</p>
<p className="mt-3 text-sm text-ink-200">
Keep this somewhere safe. Anyone with this code can open your company.
</p>
<Button block className="mt-3" onClick={() => copy(code)}>
{copied ? "Copied" : "Copy"}
</Button>
</>
);
} else if (status.isPending) {
body = <p className="mt-4 text-sm text-ink-400">Checking…</p>;
} else if (status.data?.hasCode) {
body = (
<>
<p className="mt-3 text-sm text-ink-200">
You already have a backup code. It is only shown when it is made, so if you lost it, make
a new one.
</p>
<p className="mt-2 text-sm text-amber-200">
Your old code will stop working as soon as you generate a new one.
</p>
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
Generate a new code
</Button>
</>
);
} else {
body = (
<>
<p className="mt-3 text-sm text-ink-200">
A backup code lets you open this company on any device, or get it back if this one is
lost. No email or password.
</p>
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
Create a backup code
</Button>
</>
);
}
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
Back up company
</Button>
{open && (
<Sheet titleId="backup-title" title="Back up your company" eyebrow="Backup code">
{body}
{(create.isError || status.isError) && (
<p role="alert" className="mt-2 text-sm text-rose-300">
{errorMessage(create.error ?? status.error)}
</p>
)}
<Button variant="secondary" block className="mt-3" onClick={close}>
Done
</Button>
</Sheet>
)}
</>
);
}
/**
* "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 (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
Restore a company
</Button>
{open && (
<Sheet titleId="restore-title" title="Restore a company" eyebrow="Backup code" layer="z-30">
{pastTutorial && (
<p
role="note"
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
>
This device&apos;s current company will be left behind. Back it up first if you want
to keep it.
</p>
)}
<form
className="mt-3 space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (code.trim()) restore.mutate();
}}
>
<label className="block text-sm">
<span className="text-ink-300">Backup code</span>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
className="mt-1 w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 font-mono tracking-widest text-ink-50 focus:border-amber-400 focus:outline-none"
/>
</label>
{restore.isError && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(restore.error)}
</p>
)}
<div className="flex gap-2">
<Button type="submit" block disabled={!code.trim() || restore.isPending}>
Restore
</Button>
<Button variant="secondary" block onClick={() => setOpen(false)}>
Cancel
</Button>
</div>
</form>
</Sheet>
)}
</>
);
}

View File

@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { resetMapView } from "../map/camera"; import { resetMapView } from "../map/camera";
import { BackupCompany, RestoreCompany } from "./BackupSettings";
import { CompanyName, NewCompany } from "./CompanySettings"; import { CompanyName, NewCompany } from "./CompanySettings";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences"; import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
@ -22,6 +23,8 @@ export function Settings() {
<Section title="Company"> <Section title="Company">
<CompanyName /> <CompanyName />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<BackupCompany />
<RestoreCompany pastTutorial={state.tutorial === null} />
<NewCompany /> <NewCompany />
</div> </div>
</Section> </Section>

View File

@ -3,6 +3,7 @@ import { useNavigate } from "react-router";
import { useApi } from "../api/context"; import { useApi } from "../api/context";
import { PLAYER_STATE_KEY } from "../state/player"; import { PLAYER_STATE_KEY } from "../state/player";
import { Art, Button, Icon } from "../ui"; import { Art, Button, Icon } from "../ui";
import { RestoreCompany } from "./BackupSettings";
/** First launch: create a guest account (decision: guests first, accounts later). */ /** First launch: create a guest account (decision: guests first, accounts later). */
export function StartScreen() { export function StartScreen() {
@ -58,6 +59,9 @@ export function StartScreen() {
<Icon name="send" className="size-5" /> <Icon name="send" className="size-5" />
{start.isPending ? "Starting…" : "Start your company"} {start.isPending ? "Starting…" : "Start your company"}
</Button> </Button>
<div className="relative">
<RestoreCompany pastTutorial={false} />
</div>
{start.isError && ( {start.isError && (
<p role="alert" className="relative text-sm text-rose-300"> <p role="alert" className="relative text-sm text-rose-300">
Could not start a company. Check your connection and try again. Could not start a company. Check your connection and try again.

View File

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

View File

@ -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.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.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). - [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.10 — Client: About and debug info
- [ ] Step 10K.11 — Client and server: developer tools (non-production only) - [ ] Step 10K.11 — Client and server: developer tools (non-production only)
- [ ] Step 10K.12 — Plan amendments and regression - [ ] Step 10K.12 — Plan amendments and regression