Step 10K.9: backup and restore screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
69cc5ff3a7
commit
106d44d384
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue