From 8e6021c03835b0041d8ada7c97b6ae486252a550 Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Mon, 3 Aug 2026 14:12:37 +0100 Subject: [PATCH 1/8] feat(steam-link): parse, stash and redeem steam link tokens --- src/client/SteamLink.ts | 93 ++++++++++++++++++++++++++ tests/client/SteamLink.test.ts | 117 +++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/client/SteamLink.ts create mode 100644 tests/client/SteamLink.test.ts diff --git a/src/client/SteamLink.ts b/src/client/SteamLink.ts new file mode 100644 index 0000000000..6b5d5994ea --- /dev/null +++ b/src/client/SteamLink.ts @@ -0,0 +1,93 @@ +import { getApiBase } from "./Api"; +import { getAuthHeader } from "./Auth"; + +// The desktop Electron shell's account-linking gate opens the browser at +// `#steam-link?token=`. The hash (never a query string, so it +// isn't sent to any server, and never a path, so no routing needed) carries a +// short-lived, single-use ticket that ties this browser session to the +// player's Steam account. See docs/superpowers/sdd for the full handoff. +const LINK_HASH_PREFIX = "#steam-link?"; + +const PENDING_LINK_KEY = "steam-link-pending"; + +// Pulls the opaque link token out of the URL hash the desktop shell opens the +// browser at. Returns null for any hash that isn't the steam-link one +// (including no token param), so callers can call this unconditionally on +// every page load. +export function parseSteamLinkToken(hash: string): string | null { + if (!hash.startsWith(LINK_HASH_PREFIX)) return null; + const query = hash.slice(LINK_HASH_PREFIX.length); + return new URLSearchParams(query).get("token"); +} + +// The token often needs to survive a login (magic link, Discord/Google OAuth +// redirect) before it can be redeemed against an authenticated account, so it +// is stashed in localStorage rather than held in memory. +export function stashPendingLink(token: string): void { + localStorage.setItem(PENDING_LINK_KEY, token); +} + +// Consumed on read: once taken, a stale/already-handled token can't re-fire +// on a later page load. +export function takePendingLink(): string | null { + const token = localStorage.getItem(PENDING_LINK_KEY); + if (token === null) return null; + localStorage.removeItem(PENDING_LINK_KEY); + return token; +} + +export type RedeemSteamLinkResult = + | { ok: true } + | { ok: false; reason: string }; + +// POST /auth/steam/link — redeems a link ticket against the currently +// logged-in account. Idempotent on the server (re-redeeming an already-linked +// pair also returns 200), so no special-casing is needed here for that case. +// +// Status mapping: +// 200 -> ok +// 409 -> refused; `reason` is the server's machine-readable code verbatim +// (e.g. "steam_has_progress") so the UI can render a specific +// message. Never mapped to a generic failure or reworded. +// 410 -> the ticket expired; mapped to reason "expired". +// anything else (4xx/5xx/network error) -> reason "failed". Deliberately +// distinct from "expired"/the 409 reasons: a later task (429 throttling) +// needs to tell these apart, so they must not collapse into one bucket. +export async function redeemSteamLink( + token: string, +): Promise { + try { + const response = await fetch(`${getApiBase()}/auth/steam/link`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ token }), + }); + + if (response.status === 200) { + return { ok: true }; + } + + if (response.status === 409) { + const body = await response.json().catch(() => null); + const reason = typeof body?.reason === "string" ? body.reason : "failed"; + return { ok: false, reason }; + } + + if (response.status === 410) { + return { ok: false, reason: "expired" }; + } + + console.error( + "redeemSteamLink: request failed", + response.status, + response.statusText, + ); + return { ok: false, reason: "failed" }; + } catch (e) { + console.error("redeemSteamLink: request failed", e); + return { ok: false, reason: "failed" }; + } +} diff --git a/tests/client/SteamLink.test.ts b/tests/client/SteamLink.test.ts new file mode 100644 index 0000000000..3a3947cdf7 --- /dev/null +++ b/tests/client/SteamLink.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client/ClientEnv", () => ({ + ClientEnv: { jwtAudience: () => "localhost" }, +})); + +// redeemSteamLink is authenticated; Auth is only mocked because Api.ts +// imports it at module scope. +vi.mock("../../src/client/Auth", () => ({ + getAuthHeader: vi.fn(async () => "Bearer test-jwt"), + getPlayToken: vi.fn(async () => null), + logOut: vi.fn(async () => {}), + userAuth: vi.fn(async () => false), +})); + +import { + parseSteamLinkToken, + redeemSteamLink, + stashPendingLink, + takePendingLink, +} from "../../src/client/SteamLink"; + +const res = (body: unknown, status = 200) => ({ + status, + json: async () => body, +}); + +const fetchMock = () => global.fetch as ReturnType; + +beforeEach(() => { + localStorage.clear(); + // Short-circuits getApiBase before it reads localStorage. + process.env.API_DOMAIN = "api.test"; + vi.stubGlobal("fetch", vi.fn()); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +describe("parseSteamLinkToken", () => { + it("extracts the token", () => { + expect(parseSteamLinkToken("#steam-link?token=abc123")).toBe("abc123"); + }); + it("returns null for unrelated hashes", () => { + expect(parseSteamLinkToken("#token-login?token-login=x")).toBeNull(); + expect(parseSteamLinkToken("")).toBeNull(); + }); +}); + +describe("pending link stash", () => { + it("survives a round trip and is consumed once", () => { + stashPendingLink("abc"); + expect(takePendingLink()).toBe("abc"); + expect(takePendingLink()).toBeNull(); + }); +}); + +describe("redeemSteamLink", () => { + it("posts the token with the auth header and returns ok on 200", async () => { + fetchMock().mockResolvedValueOnce(res({}, 200)); + + const result = await redeemSteamLink("tok123"); + + expect(result).toEqual({ ok: true }); + expect(fetchMock()).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock().mock.calls[0]; + expect(String(url)).toContain("/auth/steam/link"); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + token: "tok123", + }); + expect((init as RequestInit).headers as Record).toEqual( + expect.objectContaining({ Authorization: "Bearer test-jwt" }), + ); + }); + + // 200 is idempotent server-side (re-redeeming an already-linked pair also + // returns 200), so the client doesn't need to special-case that. + it("treats a repeat redemption as ok", async () => { + fetchMock().mockResolvedValueOnce(res({}, 200)); + expect(await redeemSteamLink("tok123")).toEqual({ ok: true }); + }); + + it("surfaces the server's 409 reason verbatim", async () => { + fetchMock().mockResolvedValueOnce( + res({ reason: "steam_has_progress" }, 409), + ); + + const result = await redeemSteamLink("tok123"); + + expect(result).toEqual({ ok: false, reason: "steam_has_progress" }); + }); + + it("maps 410 (expired ticket) to reason 'expired'", async () => { + fetchMock().mockResolvedValueOnce(res({}, 410)); + + const result = await redeemSteamLink("tok123"); + + expect(result).toEqual({ ok: false, reason: "expired" }); + }); + + it("does not collapse an unrelated failure into the 409/410 reasons", async () => { + fetchMock().mockResolvedValueOnce(res({}, 500)); + + const result = await redeemSteamLink("tok123"); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).not.toBe("expired"); + expect(result.reason).not.toBe("steam_has_progress"); + }); + + it("returns a failure result when the request throws", async () => { + fetchMock().mockRejectedValueOnce(new TypeError("network down")); + + const result = await redeemSteamLink("tok123"); + + expect(result.ok).toBe(false); + }); +}); From c4474c1f1a241236edfc3a937a7fc593f48e98db Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Mon, 3 Aug 2026 14:37:47 +0100 Subject: [PATCH 2/8] feat(steam-link): confirmation modal naming both accounts Adds the modal a player sees during Steam <-> web account linking: it names the Steam persona (from GET /auth/steam/link_ticket/:token) and the logged-in web account (from /users/@me, never the token) and requires explicit confirm before the link is committed. Extends SteamLink.ts with fetchSteamLinkTicket for the persona read, wires Main.ts to open the modal on #steam-link?token=... and to resume a stashed token after a login redirect, and mounts in index.html so the boot hook has an element to find. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JRMRzHbZp2VhxbxqDMw4Zr --- index.html | 1 + resources/lang/en.json | 16 ++ src/client/Main.ts | 40 +++++ src/client/SteamLink.ts | 37 +++++ src/client/SteamLinkModal.ts | 242 ++++++++++++++++++++++++++++ tests/client/SteamLink.test.ts | 45 ++++++ tests/client/SteamLinkModal.test.ts | 240 +++++++++++++++++++++++++++ 7 files changed, 621 insertions(+) create mode 100644 src/client/SteamLinkModal.ts create mode 100644 tests/client/SteamLinkModal.test.ts diff --git a/index.html b/index.html index 0a253c8b31..e386a251ec 100644 --- a/index.html +++ b/index.html @@ -310,6 +310,7 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index b42f9c6413..b90c6802e9 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1445,6 +1445,22 @@ "steam": { "link_signpost": "Have an existing OpenFront account? Account linking is coming in a later update." }, + "steam_link_modal": { + "confirm": "Link Account", + "confirm_prompt": "Link Steam {persona} with account {username}?", + "linking": "Linking…", + "load_error": "Couldn't load your account details. Please try again from Steam.", + "loading_placeholder": "…", + "reason_account_already_has_steam": "Your account is already linked to a different Steam account.", + "reason_expired": "This link has expired. Please try again from Steam.", + "reason_failed": "Something went wrong. Please try again.", + "reason_steam_has_progress": "This Steam account already has its own progress and can't be merged.", + "reason_steam_linked_elsewhere": "This Steam account is already linked to a different OpenFront account.", + "success": "Your Steam account is now linked.", + "title": "Link Steam Account", + "unknown_persona": "your Steam account", + "unknown_username": "your account" + }, "steam_user_header": { "avatar_alt": "Steam avatar", "default_name": "Steam player" diff --git a/src/client/Main.ts b/src/client/Main.ts index 0219f863a4..e776c50ea1 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -54,6 +54,9 @@ import "./NewsModal"; import "./PlayerProfileModal"; import { RewardsModal } from "./RewardsModal"; import "./SinglePlayerModal"; +import { parseSteamLinkToken, takePendingLink } from "./SteamLink"; +import "./SteamLinkModal"; +import { SteamLinkModal } from "./SteamLinkModal"; import "./SteamLinkSignpost"; import { StoreModal } from "./Store"; import { TokenLoginModal } from "./TokenLoginModal"; @@ -174,6 +177,7 @@ class Client { private tokenLoginModal: TokenLoginModal; private matchmakingModal: MatchmakingModal; private rewardsModal: RewardsModal; + private steamLinkModal: SteamLinkModal; private mostRecentJoinEvent: number; private turnstileTokenPromise: Promise<{ @@ -404,6 +408,16 @@ class Client { console.warn("Rewards modal element not found"); } + this.steamLinkModal = document.querySelector( + "steam-link-modal", + ) as SteamLinkModal; + if ( + !this.steamLinkModal || + !(this.steamLinkModal instanceof SteamLinkModal) + ) { + console.warn("Steam link modal element not found"); + } + const onUserMe = async (userMeResponse: UserMeResponse | false) => { if (crazyGamesSDK.isOnCrazyGames()) { void updateCrazyGamesNavButton(); @@ -445,6 +459,20 @@ class Client { "Sharing this ID will allow others to view your game history and stats.", ); + // Resume a Steam-link confirmation that was interrupted by a login + // redirect (Discord/Google OAuth, magic link): the modal stashed the + // token and sent the player to log in via #modal=account, so a login + // redirect commonly lands back there rather than on a clean "/" — + // this must NOT be gated on cleanHomepage below. Only read the stash + // once login is confirmed: takePendingLink() consumes it on read, so + // a speculative read while logged out would burn a token that a + // *later* successful login should still get to resume. + const pendingLink = takePendingLink(); + if (pendingLink) { + void this.steamLinkModal?.openWithToken(pendingLink); + return; + } + // Popups below only on a clean homepage load, never over a deep link // (join URL, #modal=..., #purchase-completed, ...). const cleanHomepage = @@ -750,6 +778,17 @@ class Client { return; } + // The desktop Electron shell's account-linking gate opens the browser + // here (see SteamLink.ts for the full handoff). Checked against the raw + // hash, not decodedHash — parseSteamLinkToken's prefix match is exact + // and the token itself is opaque, so no decoding is needed or expected. + const steamLinkToken = parseSteamLinkToken(hash); + if (steamLinkToken) { + strip(); + void this.steamLinkModal?.openWithToken(steamLinkToken); + return; + } + const pathMatch = window.location.pathname.match( /^\/(?:w\d+\/)?game\/([^/]+)/, ); @@ -905,6 +944,7 @@ class Client { "account-button", "leaderboard-button", "token-login", + "steam-link-modal", "matchmaking-modal", "clan-modal", "lang-selector", diff --git a/src/client/SteamLink.ts b/src/client/SteamLink.ts index 6b5d5994ea..54a6ed1a36 100644 --- a/src/client/SteamLink.ts +++ b/src/client/SteamLink.ts @@ -36,6 +36,43 @@ export function takePendingLink(): string | null { return token; } +export type SteamLinkTicketResult = + | { ok: true; personaName: string | null } + | { ok: false }; + +// GET /auth/steam/link_ticket/:token — the Steam persona for the confirmation +// modal. Unauthenticated (anyone holding the token can poll it), and the +// server response is deliberately narrow: `state`/`reason` exist for the +// desktop gate's own polling, not for this — only `personaName` is read here. +// Returns { ok: false } on any error (unknown/expired token, network failure, +// bad shape) so the modal can render a single "couldn't load" state rather +// than guessing at a name. +export async function fetchSteamLinkTicket( + token: string, +): Promise { + try { + const response = await fetch( + `${getApiBase()}/auth/steam/link_ticket/${encodeURIComponent(token)}`, + { headers: { Accept: "application/json" } }, + ); + if (response.status !== 200) { + console.warn( + "fetchSteamLinkTicket: unexpected status", + response.status, + response.statusText, + ); + return { ok: false }; + } + const body = await response.json(); + const personaName = + typeof body?.personaName === "string" ? body.personaName : null; + return { ok: true, personaName }; + } catch (e) { + console.error("fetchSteamLinkTicket: request failed", e); + return { ok: false }; + } +} + export type RedeemSteamLinkResult = | { ok: true } | { ok: false; reason: string }; diff --git a/src/client/SteamLinkModal.ts b/src/client/SteamLinkModal.ts new file mode 100644 index 0000000000..277f2cbb48 --- /dev/null +++ b/src/client/SteamLinkModal.ts @@ -0,0 +1,242 @@ +import { html, TemplateResult } from "lit"; +import { customElement } from "lit/decorators.js"; +import { getUserMe, invalidateUserMe } from "./Api"; +import { isLoggedIn } from "./Auth"; +import { BaseModal } from "./components/BaseModal"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { + fetchSteamLinkTicket, + redeemSteamLink, + stashPendingLink, +} from "./SteamLink"; +import { translateText } from "./Utils"; + +type LoadState = "loading" | "ready" | "load_error"; +type RedeemState = "idle" | "redeeming" | "success" | "failed"; + +// Known machine-readable refusal reasons from POST /auth/steam/link (see the +// status-code mapping in SteamLink.ts's redeemSteamLink). Each gets its own +// message — the reason is surfaced verbatim by the server precisely so the +// UI doesn't have to collapse it into one generic failure. A reason this +// build doesn't recognise (e.g. a future addition) falls back to the generic +// key rather than rendering a raw server code. +const REASON_KEYS: Record = { + account_already_has_steam: + "steam_link_modal.reason_account_already_has_steam", + steam_linked_elsewhere: "steam_link_modal.reason_steam_linked_elsewhere", + steam_has_progress: "steam_link_modal.reason_steam_has_progress", + expired: "steam_link_modal.reason_expired", +}; +const DEFAULT_REASON_KEY = "steam_link_modal.reason_failed"; + +const BUTTON_BASE = + "flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl " + + "transition-all disabled:opacity-50 disabled:pointer-events-none border-0"; + +/** + * Confirmation modal for the Steam <-> web account linking handoff. + * + * Opened from Main.ts's boot hook when the URL carries a + * `#steam-link?token=...` hash (see SteamLink.ts's parseSteamLinkToken), or + * when resuming a stashed token after a login completes. + * + * Security note — this is the point of the whole component. The link token + * is opaque and carries nothing about either account, and on a shared + * machine the browser may be logged into someone else's OpenFront session. + * So the two names shown here come from two different, specific places, and + * mixing them up is the defect this component exists to avoid: + * - the Steam persona comes from GET /auth/steam/link_ticket/:token + * (server-resolved from the verified Steam ticket the desktop minted); + * - the web account name comes from the logged-in session's /users/@me — + * NEVER from the token, which is attacker-controllable. + */ +@customElement("steam-link-modal") +export class SteamLinkModal extends BaseModal { + private loadState: LoadState = "loading"; + private redeemState: RedeemState = "idle"; + private failureReason: string | null = null; + + private token: string | null = null; + private personaName: string | null = null; + private username: string | null = null; + + // Guards a stale open()'s fetch/redeem continuation from clobbering state + // that belongs to a later call (a re-open with a different token, or a + // close while a request is still in flight). + private requestId = 0; + + protected modalConfig() { + return { maxWidth: "480px" }; + } + + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("steam_link_modal.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }); + } + + // Entry point. The confirm step needs the *logged-in* account's name, so + // if nobody is logged in there is nothing to confirm yet: stash the token + // (survives the login redirect) and send the player to log in instead of + // opening a confirm dialog with a blank side — that would either show + // nothing useful or, worse, tempt a fallback to something token-derived. + public async openWithToken(token: string): Promise { + if (!(await isLoggedIn())) { + stashPendingLink(token); + window.location.hash = "modal=account"; + return; + } + this.token = token; + this.open(); + } + + protected onOpen(): void { + const myRequestId = ++this.requestId; + this.loadState = "loading"; + this.redeemState = "idle"; + this.failureReason = null; + this.personaName = null; + this.username = null; + + const token = this.token; + if (token === null) { + this.loadState = "load_error"; + return; + } + + void Promise.all([fetchSteamLinkTicket(token), getUserMe()]).then( + ([ticket, userMe]) => { + if (myRequestId !== this.requestId) return; // superseded + if (!ticket.ok || userMe === false) { + this.loadState = "load_error"; + this.requestUpdate(); + return; + } + this.personaName = ticket.personaName; + this.username = userMe.player.username ?? null; + this.loadState = "ready"; + this.requestUpdate(); + }, + ); + } + + protected onClose(): void { + this.token = null; + this.requestId++; + } + + private async handleConfirm(): Promise { + if ( + this.loadState !== "ready" || + this.redeemState === "redeeming" || + this.redeemState === "success" + ) { + return; + } + const token = this.token; + if (token === null) return; + + const myRequestId = this.requestId; + this.redeemState = "redeeming"; + this.failureReason = null; + this.requestUpdate(); + + const result = await redeemSteamLink(token); + if (myRequestId !== this.requestId) return; // closed/reopened meanwhile + + if (result.ok) { + invalidateUserMe(); + this.redeemState = "success"; + } else { + this.redeemState = "failed"; + this.failureReason = result.reason; + } + this.requestUpdate(); + } + + protected renderBody(): TemplateResult { + if (this.loadState === "load_error") { + return html` +
+

+ ${translateText("steam_link_modal.load_error")} +

+ +
+ `; + } + + if (this.redeemState === "success") { + return html` +
+

+ ${translateText("steam_link_modal.success")} +

+ +
+ `; + } + + const ready = this.loadState === "ready"; + const persona = ready + ? (this.personaName ?? translateText("steam_link_modal.unknown_persona")) + : translateText("steam_link_modal.loading_placeholder"); + const account = ready + ? (this.username ?? translateText("steam_link_modal.unknown_username")) + : translateText("steam_link_modal.loading_placeholder"); + + const prompt = translateText("steam_link_modal.confirm_prompt", { + persona, + username: account, + }); + + // "success" is handled by the early return above — by construction it + // can't reach here, so only "redeeming" needs to gate the button. + const confirmDisabled = !ready || this.redeemState === "redeeming"; + const confirmLabel = + this.redeemState === "redeeming" + ? translateText("steam_link_modal.linking") + : translateText("steam_link_modal.confirm"); + + return html` +
+

${prompt}

+ ${this.redeemState === "failed" + ? html`

+ ${translateText( + REASON_KEYS[this.failureReason ?? ""] ?? DEFAULT_REASON_KEY, + )} +

` + : null} +
+ + +
+
+ `; + } +} diff --git a/tests/client/SteamLink.test.ts b/tests/client/SteamLink.test.ts index 3a3947cdf7..f29b1dc5f2 100644 --- a/tests/client/SteamLink.test.ts +++ b/tests/client/SteamLink.test.ts @@ -14,6 +14,7 @@ vi.mock("../../src/client/Auth", () => ({ })); import { + fetchSteamLinkTicket, parseSteamLinkToken, redeemSteamLink, stashPendingLink, @@ -115,3 +116,47 @@ describe("redeemSteamLink", () => { expect(result.ok).toBe(false); }); }); + +describe("fetchSteamLinkTicket", () => { + it("fetches the persona for the confirmation modal, unauthenticated", async () => { + fetchMock().mockResolvedValueOnce( + res({ state: "pending", reason: null, personaName: "Ada" }, 200), + ); + + const result = await fetchSteamLinkTicket("tok123"); + + expect(result).toEqual({ ok: true, personaName: "Ada" }); + expect(fetchMock()).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock().mock.calls[0]; + expect(String(url)).toContain("/auth/steam/link_ticket/tok123"); + // Unauthenticated: no Authorization header sent (unlike redeemSteamLink). + expect( + (init as RequestInit | undefined)?.headers as + | Record + | undefined, + ).not.toHaveProperty("Authorization"); + }); + + it("passes through a null persona (Steam declined to resolve one)", async () => { + fetchMock().mockResolvedValueOnce( + res({ state: "pending", reason: null, personaName: null }, 200), + ); + + expect(await fetchSteamLinkTicket("tok123")).toEqual({ + ok: true, + personaName: null, + }); + }); + + it("returns ok:false for an unknown/expired token (404)", async () => { + fetchMock().mockResolvedValueOnce(res({}, 404)); + + expect(await fetchSteamLinkTicket("tok123")).toEqual({ ok: false }); + }); + + it("returns ok:false when the request throws", async () => { + fetchMock().mockRejectedValueOnce(new TypeError("network down")); + + expect(await fetchSteamLinkTicket("tok123")).toEqual({ ok: false }); + }); +}); diff --git a/tests/client/SteamLinkModal.test.ts b/tests/client/SteamLinkModal.test.ts new file mode 100644 index 0000000000..306b8eb529 --- /dev/null +++ b/tests/client/SteamLinkModal.test.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserMeResponse } from "../../src/core/ApiSchemas"; + +// ─── Mocks ─────────────────────────────────────────────────────────────── + +const isLoggedInMock = vi.hoisted(() => vi.fn()); +const stashPendingLinkMock = vi.hoisted(() => vi.fn()); +const fetchSteamLinkTicketMock = vi.hoisted(() => vi.fn()); +const redeemSteamLinkMock = vi.hoisted(() => vi.fn()); +const getUserMeMock = vi.hoisted(() => vi.fn()); +const invalidateUserMeMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../src/client/Auth", () => ({ + isLoggedIn: isLoggedInMock, +})); + +// This is the interface produced by the previous task (SteamLink.ts). Mocking +// it here means this file tests the modal's UI/wiring only — parseSteamLinkToken +// / redeem status-mapping already has its own coverage in SteamLink.test.ts. +vi.mock("../../src/client/SteamLink", () => ({ + stashPendingLink: stashPendingLinkMock, + fetchSteamLinkTicket: fetchSteamLinkTicketMock, + redeemSteamLink: redeemSteamLinkMock, +})); + +vi.mock("../../src/client/Api", () => ({ + getUserMe: getUserMeMock, + invalidateUserMe: invalidateUserMeMock, +})); + +vi.mock("../../src/client/Utils", () => ({ + translateText: vi.fn((key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + ), +})); + +import { SteamLinkModal } from "../../src/client/SteamLinkModal"; + +function makeUserMe(username: string | null): UserMeResponse { + return { + user: {}, + player: { + publicId: "p1", + adfree: false, + unlimitedRanked: false, + canCreatePublicLobbies: false, + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + username, + }, + }; +} + +// A promise whose resolution the test controls, so the "still loading" state +// can be inspected deterministically before letting it settle. +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("SteamLinkModal", () => { + let modal: SteamLinkModal; + + beforeEach(async () => { + vi.clearAllMocks(); + history.replaceState(null, "", "/"); + if (!customElements.get("steam-link-modal")) { + customElements.define("steam-link-modal", SteamLinkModal); + } + modal = document.createElement("steam-link-modal") as SteamLinkModal; + document.body.appendChild(modal); + await modal.updateComplete; + }); + + afterEach(() => { + modal.remove(); + history.replaceState(null, "", "/"); + }); + + const confirmButton = () => + modal.querySelector("button.steam-link-confirm-btn"); + + it("when not logged in, stashes the token and triggers the login flow instead of confirming", async () => { + isLoggedInMock.mockResolvedValue(false); + + await modal.openWithToken("tok-abc"); + await modal.updateComplete; + + expect(stashPendingLinkMock).toHaveBeenCalledWith("tok-abc"); + // "Trigger the login flow" = route to the account modal, which shows the + // login options for a logged-out visitor (see AccountModal). + expect(window.location.hash).toBe("#modal=account"); + expect(modal.isOpen()).toBe(false); + expect(fetchSteamLinkTicketMock).not.toHaveBeenCalled(); + expect(getUserMeMock).not.toHaveBeenCalled(); + expect(redeemSteamLinkMock).not.toHaveBeenCalled(); + }); + + it("renders both names once loaded, and disables confirm until both have loaded", async () => { + isLoggedInMock.mockResolvedValue(true); + const ticket = deferred<{ ok: true; personaName: string | null }>(); + const userMe = deferred(); + fetchSteamLinkTicketMock.mockReturnValue(ticket.promise); + getUserMeMock.mockReturnValue(userMe.promise); + + await modal.openWithToken("tok-abc"); + await modal.updateComplete; + + expect(modal.isOpen()).toBe(true); + // Still in flight: nothing to confirm yet, so confirm must be disabled. + expect(confirmButton()?.disabled).toBe(true); + + ticket.resolve({ ok: true, personaName: "Ada" }); + userMe.resolve(makeUserMe("web.1234")); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + // Both names present in the rendered prompt — persona from the ticket + // endpoint, account name from /users/@me. + expect(modal.textContent).toContain("Ada"); + expect(modal.textContent).toContain("web.1234"); + }); + + it("shows a load-error state with no confirm control when the ticket fetch fails", async () => { + isLoggedInMock.mockResolvedValue(true); + fetchSteamLinkTicketMock.mockResolvedValue({ ok: false }); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + + await modal.openWithToken("tok-abc"); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.textContent).toContain("steam_link_modal.load_error"); + }); + expect(confirmButton()).toBeNull(); + expect(redeemSteamLinkMock).not.toHaveBeenCalled(); + }); + + it("shows a load-error state with no confirm control when /users/@me fails", async () => { + isLoggedInMock.mockResolvedValue(true); + fetchSteamLinkTicketMock.mockResolvedValue({ + ok: true, + personaName: "Ada", + }); + getUserMeMock.mockResolvedValue(false); + + await modal.openWithToken("tok-abc"); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.textContent).toContain("steam_link_modal.load_error"); + }); + expect(confirmButton()).toBeNull(); + }); + + it("renders a specific message per refusal reason rather than a generic failure", async () => { + isLoggedInMock.mockResolvedValue(true); + fetchSteamLinkTicketMock.mockResolvedValue({ + ok: true, + personaName: "Ada", + }); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + redeemSteamLinkMock.mockResolvedValue({ + ok: false, + reason: "steam_has_progress", + }); + + await modal.openWithToken("tok-abc"); + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + confirmButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.textContent).toContain( + "steam_link_modal.reason_steam_has_progress", + ); + }); + // Not the generic bucket — a specific reason was rendered instead. + expect(modal.textContent).not.toContain("steam_link_modal.reason_failed"); + }); + + it("falls back to a generic message for an unrecognised refusal reason", async () => { + isLoggedInMock.mockResolvedValue(true); + fetchSteamLinkTicketMock.mockResolvedValue({ + ok: true, + personaName: "Ada", + }); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + redeemSteamLinkMock.mockResolvedValue({ ok: false, reason: "failed" }); + + await modal.openWithToken("tok-abc"); + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + confirmButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.textContent).toContain("steam_link_modal.reason_failed"); + }); + }); + + it("redeems on confirm and shows success, invalidating the cached /users/@me", async () => { + isLoggedInMock.mockResolvedValue(true); + fetchSteamLinkTicketMock.mockResolvedValue({ + ok: true, + personaName: "Ada", + }); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + redeemSteamLinkMock.mockResolvedValue({ ok: true }); + + await modal.openWithToken("tok-abc"); + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + confirmButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.textContent).toContain("steam_link_modal.success"); + }); + expect(redeemSteamLinkMock).toHaveBeenCalledWith("tok-abc"); + expect(invalidateUserMeMock).toHaveBeenCalled(); + }); +}); From 8dc95b67144f18bb915cdda6516bd73ef499912c Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Mon, 3 Aug 2026 14:42:01 +0100 Subject: [PATCH 3/8] fix(steam-link): add 401/no-session guards, fix weak idempotency test - redeemSteamLink now clears a stale JWT on 401 (logOut()), matching the convention every other authenticated call in Api.ts follows. - Guard against firing with an empty Authorization header (mirrors linkGoogle's guard in Auth.ts). - Fixed the status-mapping comment to describe current behavior (429/500/ network errors all collapse into "failed" today) rather than a future task's. - Rewrote the "treats a repeat redemption as ok" test to actually call redeemSteamLink twice; it previously called it once and could not fail for the reason its name claimed. --- src/client/SteamLink.ts | 25 +++++++++++++++++++----- tests/client/SteamLink.test.ts | 35 ++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/client/SteamLink.ts b/src/client/SteamLink.ts index 54a6ed1a36..d2b822aed0 100644 --- a/src/client/SteamLink.ts +++ b/src/client/SteamLink.ts @@ -1,5 +1,5 @@ import { getApiBase } from "./Api"; -import { getAuthHeader } from "./Auth"; +import { getAuthHeader, logOut } from "./Auth"; // The desktop Electron shell's account-linking gate opens the browser at // `#steam-link?token=`. The hash (never a query string, so it @@ -83,22 +83,32 @@ export type RedeemSteamLinkResult = // // Status mapping: // 200 -> ok +// 401 -> stale cached JWT; clears it via logOut() before failing, matching +// the convention every other authenticated call in Api.ts follows +// (e.g. setMarketingConsent, updateUsername, getMyTribeNames). // 409 -> refused; `reason` is the server's machine-readable code verbatim // (e.g. "steam_has_progress") so the UI can render a specific // message. Never mapped to a generic failure or reworded. // 410 -> the ticket expired; mapped to reason "expired". -// anything else (4xx/5xx/network error) -> reason "failed". Deliberately -// distinct from "expired"/the 409 reasons: a later task (429 throttling) -// needs to tell these apart, so they must not collapse into one bucket. +// anything else (4xx/5xx/network error, including 429) -> reason "failed". +// This collapses 429/500/network errors into one generic bucket for now; a +// later task adds Retry-After-aware 429 handling and will need to split +// that case out then, not here. export async function redeemSteamLink( token: string, ): Promise { try { + // Mirrors linkGoogle's guard in Auth.ts: getAuthHeader() returns "" rather + // than throwing when logged out, and firing with an empty Authorization + // header would just bounce off the server as a confusing failure. + const authHeader = await getAuthHeader(); + if (authHeader === "") return { ok: false, reason: "failed" }; + const response = await fetch(`${getApiBase()}/auth/steam/link`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: await getAuthHeader(), + Authorization: authHeader, }, body: JSON.stringify({ token }), }); @@ -107,6 +117,11 @@ export async function redeemSteamLink( return { ok: true }; } + if (response.status === 401) { + await logOut(); + return { ok: false, reason: "failed" }; + } + if (response.status === 409) { const body = await response.json().catch(() => null); const reason = typeof body?.reason === "string" ? body.reason : "failed"; diff --git a/tests/client/SteamLink.test.ts b/tests/client/SteamLink.test.ts index f29b1dc5f2..f782ad38b1 100644 --- a/tests/client/SteamLink.test.ts +++ b/tests/client/SteamLink.test.ts @@ -13,6 +13,7 @@ vi.mock("../../src/client/Auth", () => ({ userAuth: vi.fn(async () => false), })); +import { getAuthHeader, logOut } from "../../src/client/Auth"; import { fetchSteamLinkTicket, parseSteamLinkToken, @@ -34,6 +35,12 @@ beforeEach(() => { process.env.API_DOMAIN = "api.test"; vi.stubGlobal("fetch", vi.fn()); vi.spyOn(console, "error").mockImplementation(() => {}); + // Clears call history from the previous test while keeping the default + // "Bearer test-jwt" / no-op implementations set in the vi.mock factories + // above (some tests below override getAuthHeader for a single call via + // mockResolvedValueOnce, so this must not touch that default). + vi.mocked(getAuthHeader).mockClear(); + vi.mocked(logOut).mockClear(); }); describe("parseSteamLinkToken", () => { @@ -73,10 +80,34 @@ describe("redeemSteamLink", () => { }); // 200 is idempotent server-side (re-redeeming an already-linked pair also - // returns 200), so the client doesn't need to special-case that. + // returns 200), so the client doesn't need to special-case that — it can + // just call again and get another ok. it("treats a repeat redemption as ok", async () => { - fetchMock().mockResolvedValueOnce(res({}, 200)); + fetchMock() + .mockResolvedValueOnce(res({}, 200)) + .mockResolvedValueOnce(res({}, 200)); + expect(await redeemSteamLink("tok123")).toEqual({ ok: true }); + expect(await redeemSteamLink("tok123")).toEqual({ ok: true }); + expect(fetchMock()).toHaveBeenCalledTimes(2); + }); + + it("refuses to fire when there is no auth session", async () => { + vi.mocked(getAuthHeader).mockResolvedValueOnce(""); + + const result = await redeemSteamLink("tok123"); + + expect(result.ok).toBe(false); + expect(fetchMock()).not.toHaveBeenCalled(); + }); + + it("clears the stale session and fails on 401", async () => { + fetchMock().mockResolvedValueOnce(res({}, 401)); + + const result = await redeemSteamLink("tok123"); + + expect(result.ok).toBe(false); + expect(logOut).toHaveBeenCalledTimes(1); }); it("surfaces the server's 409 reason verbatim", async () => { From 80e086ed7165129d88d4fe577e19cfe1c6934448 Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Mon, 3 Aug 2026 15:23:51 +0100 Subject: [PATCH 4/8] feat(steam-link): desktop re-entry from the account modal Adds a "Link an existing account" action to the account modal's Account tab that re-opens the Electron desktop shell's account-linking gate via window.openfrontDesktop.showLinkGate(). The gate is shown at first launch but the game will eventually run fullscreen borderless with the menu bar hidden, so a player who dismissed it (or wants to link later) needs another way back in. Absent entirely on plain web, since window.openfrontDesktop doesn't exist there. Guards specifically on showLinkGate being callable rather than a sibling property (window.openfrontDesktop.linkGate, used separately by the gate page itself) so a rename of one can't silently leave this button wired to nothing. --- resources/lang/en.json | 1 + src/client/AccountModal.ts | 46 ++++++++++++- tests/client/AccountModal.rendering.test.ts | 73 +++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index b90c6802e9..44423af5e6 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -23,6 +23,7 @@ "get_magic_link": "Get Magic Link", "google_alt": "Google", "link_discord": "Link Discord Account", + "link_existing_account": "Link an existing account", "link_google": "Link Google Account", "link_google_already_linked": "That Google account is already linked to another player.", "link_google_error": "Couldn't link your Google account. Please make sure you're signed in and try again.", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 1f8bdeb1da..230c300dcf 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -40,6 +40,26 @@ import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK"; import { playerProfileUrl } from "./PlayerProfileModal"; import { translateText } from "./Utils"; +// window.openfrontDesktop is declared `unknown` by DesktopShell.ts (kept loose +// there on purpose). We know the one function we need, so narrow it locally +// rather than re-declaring the global (a second `declare global` with a +// different type triggers TS2717) — mirrors SteamSDK.ts's steamBridge(). +// +// Guard on `showLinkGate` specifically, the function actually invoked below — +// not on a sibling property like `linkGate` (a separate namespace used by the +// gate page itself) — so a rename of one can't silently leave this button +// wired to nothing. +function desktopLinkGateBridge(): + | { showLinkGate: () => Promise } + | undefined { + const desktop = window.openfrontDesktop as + | { showLinkGate?: unknown } + | undefined; + return typeof desktop?.showLinkGate === "function" + ? (desktop as { showLinkGate: () => Promise }) + : undefined; +} + @customElement("account-modal") export class AccountModal extends BaseModal { protected routerName = "account"; @@ -354,11 +374,35 @@ export class AccountModal extends BaseModal { ${this.renderUsernamePanel()} ${this.renderRewardsPanel()} - ${this.renderSubscriptionPanel()} + ${this.renderSubscriptionPanel()} ${this.renderDesktopLinkGateAction()} `; } + // Re-entry to the desktop shell's account-linking gate shown at first + // launch. Absent entirely on plain web (no window.openfrontDesktop there), + // present whenever the desktop bridge exposes a callable showLinkGate — + // see desktopLinkGateBridge() above for why the guard is scoped that way. + // Needed because the desktop app's menu bar will eventually be hidden and + // the game runs fullscreen borderless, so a dismissed or since-linked + // player needs another way back to that gate. + private renderDesktopLinkGateAction(): TemplateResult | typeof nothing { + if (!desktopLinkGateBridge()) return nothing; + return html` + + `; + } + + private handleShowLinkGate(): void { + void desktopLinkGateBridge()?.showLinkGate(); + } + // CrazyGames "connected as" view: avatar + username from the SDK, plus // currency/subscription. No Discord/Google/email link or logout (CrazyGames // owns the account and its logout). diff --git a/tests/client/AccountModal.rendering.test.ts b/tests/client/AccountModal.rendering.test.ts index 29d496c033..e9424f89c7 100644 --- a/tests/client/AccountModal.rendering.test.ts +++ b/tests/client/AccountModal.rendering.test.ts @@ -88,6 +88,7 @@ describe("AccountModal — rendering", () => { afterEach(() => { document.body.removeChild(modal); vi.clearAllMocks(); + delete (window as { openfrontDesktop?: unknown }).openfrontDesktop; }); // Directly install a resolved userMeResponse and flip off the loading state, @@ -163,4 +164,76 @@ describe("AccountModal — rendering", () => { expect(modal.querySelector("currency-display")).toBeTruthy(); expect(text).toContain("account_modal.log_out"); }); + + // Desktop re-entry to the account-linking gate. The Electron preload exposes + // `window.openfrontDesktop.showLinkGate()` for exactly this purpose; it is + // absent entirely on plain web, which is the signal the action guards on. + describe("desktop link-gate action", () => { + function findLinkGateButton(): HTMLButtonElement | undefined { + return Array.from(modal.querySelectorAll("button")).find((b) => + b.textContent?.includes("account_modal.link_existing_account"), + ); + } + + it("renders and calls showLinkGate() when the desktop bridge is present", async () => { + const showLinkGate = vi.fn(async () => undefined); + (window as unknown as { openfrontDesktop: unknown }).openfrontDesktop = { + showLinkGate, + }; + + const userMe = makeUserMe({ + discord: { + id: "1", + avatar: null, + username: "player", + global_name: null, + discriminator: "0", + }, + }); + await setLoggedInUser(userMe); + + const button = findLinkGateButton(); + expect(button).toBeTruthy(); + + button!.click(); + expect(showLinkGate).toHaveBeenCalledTimes(1); + }); + + it("does not render when the desktop bridge is absent (plain web)", async () => { + const userMe = makeUserMe({ + discord: { + id: "1", + avatar: null, + username: "player", + global_name: null, + discriminator: "0", + }, + }); + await setLoggedInUser(userMe); + + expect(findLinkGateButton()).toBeUndefined(); + }); + + // Pins the resolved ambiguity from the plan: guard on the function we + // actually call, not a sibling property. A bridge exposing `linkGate` but + // no callable `showLinkGate` must not render a dead button. + it("does not render when the bridge exists but showLinkGate is not a function", async () => { + (window as unknown as { openfrontDesktop: unknown }).openfrontDesktop = { + linkGate: { open: vi.fn() }, + }; + + const userMe = makeUserMe({ + discord: { + id: "1", + avatar: null, + username: "player", + global_name: null, + discriminator: "0", + }, + }); + await setLoggedInUser(userMe); + + expect(findLinkGateButton()).toBeUndefined(); + }); + }); }); From c393282b62c0b5b9091a6e6b902398fad7720ed2 Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Mon, 3 Aug 2026 15:49:18 +0100 Subject: [PATCH 5/8] feat(steam-link): code-entry form for the desktop gate's fallback code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop gate's browser-handoff can fail (wrong default browser, an odd Linux setup, Steam's overlay browser), in which case it shows an 8-character code and tells the player to enter it on the website instead. Nothing in the web client offered anywhere to type one. Reuses Task 15's confirmation modal rather than a second one: a new openForCodeEntry() entry point normalizes/validates the code client-side, then proceeds to the same ready/confirm render path the token flow uses. There is no server-side persona lookup keyed by code (only by token), so the confirm step shows the real, logged-in web account name and falls back to the existing "unknown persona" copy for the Steam side rather than inventing one. Also splits 429 out of redeemSteamLink's generic "failed" bucket into a distinct rate_limited reason with a parsed Retry-After, shared with the new redeemSteamLinkCode via a common postSteamLinkRedeem helper — the throttle refuses even a correct code/token once tripped, so collapsing it into "that was wrong" would be misleading. Existing 200/401/409/410 mappings and their tests are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JRMRzHbZp2VhxbxqDMw4Zr --- resources/lang/en.json | 5 + src/client/Main.ts | 18 ++- src/client/SteamLink.ts | 111 +++++++++++++--- src/client/SteamLinkModal.ts | 189 ++++++++++++++++++++++++++-- tests/client/SteamLink.test.ts | 135 +++++++++++++++++++- tests/client/SteamLinkModal.test.ts | 164 +++++++++++++++++++++++- 6 files changed, 588 insertions(+), 34 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 44423af5e6..17abd5cf28 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1447,14 +1447,19 @@ "link_signpost": "Have an existing OpenFront account? Account linking is coming in a later update." }, "steam_link_modal": { + "code_placeholder": "XXXX-XXXX", + "code_prompt": "Enter the code shown on your Steam desktop app.", + "code_submit": "Continue", "confirm": "Link Account", "confirm_prompt": "Link Steam {persona} with account {username}?", + "invalid_code": "That doesn't look like a valid code. Check it and try again.", "linking": "Linking…", "load_error": "Couldn't load your account details. Please try again from Steam.", "loading_placeholder": "…", "reason_account_already_has_steam": "Your account is already linked to a different Steam account.", "reason_expired": "This link has expired. Please try again from Steam.", "reason_failed": "Something went wrong. Please try again.", + "reason_rate_limited": "Too many attempts. Please wait {seconds, plural, =0 {a moment} one {# second} other {# seconds}} and try again.", "reason_steam_has_progress": "This Steam account already has its own progress and can't be merged.", "reason_steam_linked_elsewhere": "This Steam account is already linked to a different OpenFront account.", "success": "Your Steam account is now linked.", diff --git a/src/client/Main.ts b/src/client/Main.ts index e776c50ea1..9146dd5150 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -54,7 +54,11 @@ import "./NewsModal"; import "./PlayerProfileModal"; import { RewardsModal } from "./RewardsModal"; import "./SinglePlayerModal"; -import { parseSteamLinkToken, takePendingLink } from "./SteamLink"; +import { + isSteamLinkHash, + parseSteamLinkToken, + takePendingLink, +} from "./SteamLink"; import "./SteamLinkModal"; import { SteamLinkModal } from "./SteamLinkModal"; import "./SteamLinkSignpost"; @@ -789,6 +793,18 @@ class Client { return; } + // Fallback: the gate's browser handoff itself can fail (wrong default + // browser, an odd Linux setup, Steam's overlay browser), in which case it + // shows an 8-character code instead and tells the player to enter it on + // the website. There's no token in that case, so parseSteamLinkToken + // above returns null — this is the bare `#steam-link` hash the code path + // lands on instead (see SteamLink.ts's isSteamLinkHash). + if (isSteamLinkHash(hash)) { + strip(); + void this.steamLinkModal?.openForCodeEntry(); + return; + } + const pathMatch = window.location.pathname.match( /^\/(?:w\d+\/)?game\/([^/]+)/, ); diff --git a/src/client/SteamLink.ts b/src/client/SteamLink.ts index d2b822aed0..43dd01f471 100644 --- a/src/client/SteamLink.ts +++ b/src/client/SteamLink.ts @@ -7,6 +7,7 @@ import { getAuthHeader, logOut } from "./Auth"; // short-lived, single-use ticket that ties this browser session to the // player's Steam account. See docs/superpowers/sdd for the full handoff. const LINK_HASH_PREFIX = "#steam-link?"; +const LINK_HASH = "#steam-link"; const PENDING_LINK_KEY = "steam-link-pending"; @@ -20,6 +21,46 @@ export function parseSteamLinkToken(hash: string): string | null { return new URLSearchParams(query).get("token"); } +// True for any #steam-link hash, whether or not it carries a token. The gate +// also has a fallback path with no URL at all: when the browser handoff +// itself fails (wrong default browser, an odd Linux setup, Steam's overlay +// browser), the gate shows an 8-character code instead and tells the player +// to enter it on the website. The bare hash (no ?token=) is that code-entry +// destination — Main.ts opens the code-entry form for it, since +// parseSteamLinkToken above returns null and there is otherwise nothing to +// route to. +export function isSteamLinkHash(hash: string): boolean { + return hash === LINK_HASH || hash.startsWith(LINK_HASH_PREFIX); +} + +// The fallback code's alphabet, fixed by the desktop gate that generates it: +// 8 characters, uppercase, drawn from 23456789ABCDEFGHJKMNPQRSTVWXYZ. It +// deliberately excludes 0/O, 1/I/L and U so nothing is ever ambiguous by eye +// — a code containing one of those is malformed, not a typo to silently +// correct, so normalization below never remaps characters. +const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ"; +const CODE_LENGTH = 8; + +// Normalizes what a human is expected to type when copying the code by eye +// from the desktop gate's `XXXX-XXXX` display: any case, surrounding +// whitespace, and the hyphen (presentation-only — never part of the code +// itself). Nothing else is corrected; a genuinely wrong character stays +// wrong and isValidSteamLinkCode below will reject it. +export function normalizeSteamLinkCode(raw: string): string { + return raw.trim().toUpperCase().replace(/-/g, ""); +} + +// Validates an already-normalized code against the fixed alphabet/length. +// Callers should normalize first — this does not trim/uppercase/strip +// hyphens itself, so a raw, un-normalized string will usually fail here even +// if it would have been valid once normalized. +export function isValidSteamLinkCode(code: string): boolean { + return ( + code.length === CODE_LENGTH && + [...code].every((ch) => CODE_ALPHABET.includes(ch)) + ); +} + // The token often needs to survive a login (magic link, Discord/Google OAuth // redirect) before it can be redeemed against an authenticated account, so it // is stashed in localStorage rather than held in memory. @@ -47,6 +88,12 @@ export type SteamLinkTicketResult = // Returns { ok: false } on any error (unknown/expired token, network failure, // bad shape) so the modal can render a single "couldn't load" state rather // than guessing at a name. +// +// There is no equivalent lookup for the fallback code: the server only +// resolves a persona from the verified token the desktop minted, and a code +// alone carries nothing that keys into that (see redeemSteamLinkCode below, +// and SteamLinkModal's code-entry path — it shows the confirm step with no +// persona name rather than calling this with a code). export async function fetchSteamLinkTicket( token: string, ): Promise { @@ -75,13 +122,15 @@ export async function fetchSteamLinkTicket( export type RedeemSteamLinkResult = | { ok: true } - | { ok: false; reason: string }; + | { ok: false; reason: string; retryAfterSeconds?: number | null }; -// POST /auth/steam/link — redeems a link ticket against the currently -// logged-in account. Idempotent on the server (re-redeeming an already-linked -// pair also returns 200), so no special-casing is needed here for that case. +// POST /auth/steam/link — redeems a link ticket (token or code) against the +// currently logged-in account. Idempotent on the server (re-redeeming an +// already-linked pair also returns 200), so no special-casing is needed here +// for that case. // -// Status mapping: +// Status mapping (shared by redeemSteamLink and redeemSteamLinkCode below — +// same endpoint, same throttle, just a different body shape): // 200 -> ok // 401 -> stale cached JWT; clears it via logOut() before failing, matching // the convention every other authenticated call in Api.ts follows @@ -90,12 +139,16 @@ export type RedeemSteamLinkResult = // (e.g. "steam_has_progress") so the UI can render a specific // message. Never mapped to a generic failure or reworded. // 410 -> the ticket expired; mapped to reason "expired". -// anything else (4xx/5xx/network error, including 429) -> reason "failed". -// This collapses 429/500/network errors into one generic bucket for now; a -// later task adds Retry-After-aware 429 handling and will need to split -// that case out then, not here. -export async function redeemSteamLink( - token: string, +// 429 -> the throttle tripped. This refuses even a correct token/code, so +// it must never collapse into "failed" (which the UI renders as +// "that was wrong" — actively misleading here). Mapped to reason +// "rate_limited" with retryAfterSeconds parsed from the Retry-After +// response header when present (RFC 9110 §10.2.3's delay-seconds +// form; an HTTP-date form or a missing/stripped header both degrade +// to null rather than throwing). +// anything else (4xx/5xx/network error) -> reason "failed". +async function postSteamLinkRedeem( + body: Record, ): Promise { try { // Mirrors linkGoogle's guard in Auth.ts: getAuthHeader() returns "" rather @@ -110,7 +163,7 @@ export async function redeemSteamLink( "Content-Type": "application/json", Authorization: authHeader, }, - body: JSON.stringify({ token }), + body: JSON.stringify(body), }); if (response.status === 200) { @@ -123,8 +176,11 @@ export async function redeemSteamLink( } if (response.status === 409) { - const body = await response.json().catch(() => null); - const reason = typeof body?.reason === "string" ? body.reason : "failed"; + const responseBody = await response.json().catch(() => null); + const reason = + typeof responseBody?.reason === "string" + ? responseBody.reason + : "failed"; return { ok: false, reason }; } @@ -132,6 +188,15 @@ export async function redeemSteamLink( return { ok: false, reason: "expired" }; } + if (response.status === 429) { + const retryAfterHeader = response.headers.get("Retry-After"); + const retryAfterSeconds = + retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader) + ? Number(retryAfterHeader) + : null; + return { ok: false, reason: "rate_limited", retryAfterSeconds }; + } + console.error( "redeemSteamLink: request failed", response.status, @@ -143,3 +208,21 @@ export async function redeemSteamLink( return { ok: false, reason: "failed" }; } } + +export async function redeemSteamLink( + token: string, +): Promise { + return postSteamLinkRedeem({ token }); +} + +// Redeems the desktop gate's 8-character fallback code instead of the opaque +// token — same endpoint and status mapping as redeemSteamLink (see +// postSteamLinkRedeem), just `{ code }` in the body instead of `{ token }`. +// Callers are expected to have already normalized/validated the code (see +// normalizeSteamLinkCode/isValidSteamLinkCode); this sends whatever string +// it's given, same as redeemSteamLink does for a token. +export async function redeemSteamLinkCode( + code: string, +): Promise { + return postSteamLinkRedeem({ code }); +} diff --git a/src/client/SteamLinkModal.ts b/src/client/SteamLinkModal.ts index 277f2cbb48..06bbc44476 100644 --- a/src/client/SteamLinkModal.ts +++ b/src/client/SteamLinkModal.ts @@ -6,26 +6,39 @@ import { BaseModal } from "./components/BaseModal"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchSteamLinkTicket, + isValidSteamLinkCode, + normalizeSteamLinkCode, redeemSteamLink, + redeemSteamLinkCode, stashPendingLink, } from "./SteamLink"; import { translateText } from "./Utils"; -type LoadState = "loading" | "ready" | "load_error"; +// "code_entry" is a step before "loading"/"ready"/"load_error" exist at all — +// the player hasn't given us a code yet, so there's nothing to fetch. +type LoadState = "code_entry" | "loading" | "ready" | "load_error"; type RedeemState = "idle" | "redeeming" | "success" | "failed"; +type Mode = "token" | "code"; // Known machine-readable refusal reasons from POST /auth/steam/link (see the -// status-code mapping in SteamLink.ts's redeemSteamLink). Each gets its own -// message — the reason is surfaced verbatim by the server precisely so the -// UI doesn't have to collapse it into one generic failure. A reason this -// build doesn't recognise (e.g. a future addition) falls back to the generic -// key rather than rendering a raw server code. +// status-code mapping in SteamLink.ts's redeemSteamLink/redeemSteamLinkCode). +// Each gets its own message — the reason is surfaced verbatim by the server +// precisely so the UI doesn't have to collapse it into one generic failure. +// A reason this build doesn't recognise (e.g. a future addition) falls back +// to the generic key rather than rendering a raw server code. +// +// "rate_limited" is client-synthesized (not a server reason string) by +// SteamLink.ts when the throttle returns 429 — it must render its own +// "please wait" message rather than the generic failure key, since the +// throttle refuses even a correct token/code and "that was wrong" would be +// actively misleading. const REASON_KEYS: Record = { account_already_has_steam: "steam_link_modal.reason_account_already_has_steam", steam_linked_elsewhere: "steam_link_modal.reason_steam_linked_elsewhere", steam_has_progress: "steam_link_modal.reason_steam_has_progress", expired: "steam_link_modal.reason_expired", + rate_limited: "steam_link_modal.reason_rate_limited", }; const DEFAULT_REASON_KEY = "steam_link_modal.reason_failed"; @@ -49,17 +62,39 @@ const BUTTON_BASE = * (server-resolved from the verified Steam ticket the desktop minted); * - the web account name comes from the logged-in session's /users/@me — * NEVER from the token, which is attacker-controllable. + * + * Also opened via openForCodeEntry() when the browser handoff itself fails + * (wrong default browser, an odd Linux setup, Steam's overlay browser) and + * the desktop gate falls back to showing an 8-character code instead. That + * path has no token, so no GET /auth/steam/link_ticket/:token lookup is + * possible either — there is nothing that resolves a persona from a code + * alone, and this component does not invent one. The confirm step still + * appears (still names the *web* account from /users/@me, still requires an + * explicit click), it just falls back to the same "unknown persona" copy + * already used when Steam itself declines to resolve a name. */ @customElement("steam-link-modal") export class SteamLinkModal extends BaseModal { + private mode: Mode = "token"; private loadState: LoadState = "loading"; private redeemState: RedeemState = "idle"; private failureReason: string | null = null; + // Only meaningful when failureReason === "rate_limited"; see + // SteamLink.ts's Retry-After parsing. + private retryAfterSeconds: number | null = null; private token: string | null = null; private personaName: string | null = null; private username: string | null = null; + // Code-entry state. `code` is the normalized, validated code once the + // player has submitted one; `codeDraft` mirrors the input's live value so + // the field stays controlled; `codeError` holds an inline validation + // message when the submitted draft doesn't parse. + private code: string | null = null; + private codeDraft = ""; + private codeError: string | null = null; + // Guards a stale open()'s fetch/redeem continuation from clobbering state // that belongs to a later call (a re-open with a different token, or a // close while a request is still in flight). @@ -88,18 +123,45 @@ export class SteamLinkModal extends BaseModal { window.location.hash = "modal=account"; return; } + this.mode = "token"; this.token = token; this.open(); } + // Entry point for the fallback code (see the class doc comment above). + // Same login precondition as openWithToken and for the same reason: the + // confirm step needs the logged-in account's name. Unlike a token, a + // not-yet-submitted code isn't stashed across the login redirect — there's + // nothing typed yet to preserve, so the player just re-opens this after + // logging in and re-enters it. That's a minor inconvenience (a few + // keystrokes), not a lost flow. + public async openForCodeEntry(): Promise { + if (!(await isLoggedIn())) { + window.location.hash = "modal=account"; + return; + } + this.mode = "code"; + this.token = null; + this.open(); + } + protected onOpen(): void { const myRequestId = ++this.requestId; - this.loadState = "loading"; this.redeemState = "idle"; this.failureReason = null; + this.retryAfterSeconds = null; this.personaName = null; this.username = null; + if (this.mode === "code") { + this.loadState = "code_entry"; + this.code = null; + this.codeDraft = ""; + this.codeError = null; + return; + } + + this.loadState = "loading"; const token = this.token; if (token === null) { this.loadState = "load_error"; @@ -124,9 +186,51 @@ export class SteamLinkModal extends BaseModal { protected onClose(): void { this.token = null; + this.code = null; + this.codeDraft = ""; + this.codeError = null; this.requestId++; } + private handleCodeInput(e: Event): void { + this.codeDraft = (e.target as HTMLInputElement).value; + } + + // Validates client-side before touching the network at all: a malformed + // code (wrong length, or containing a character the alphabet deliberately + // excludes — see SteamLink.ts) is rejected here rather than guessed at or + // sent to the server to reject. + private handleCodeSubmit(): void { + const normalized = normalizeSteamLinkCode(this.codeDraft); + if (!isValidSteamLinkCode(normalized)) { + this.codeError = translateText("steam_link_modal.invalid_code"); + this.requestUpdate(); + return; + } + + const myRequestId = this.requestId; + this.code = normalized; + this.codeError = null; + this.loadState = "loading"; + this.requestUpdate(); + + // No ticket to fetch for a code (see the class doc comment) — just the + // logged-in account's name. personaName stays null, which the ready-state + // render already falls back to "unknown_persona" for. + void getUserMe().then((userMe) => { + if (myRequestId !== this.requestId) return; // superseded + if (userMe === false) { + this.loadState = "load_error"; + this.requestUpdate(); + return; + } + this.personaName = null; + this.username = userMe.player.username ?? null; + this.loadState = "ready"; + this.requestUpdate(); + }); + } + private async handleConfirm(): Promise { if ( this.loadState !== "ready" || @@ -135,15 +239,25 @@ export class SteamLinkModal extends BaseModal { ) { return; } - const token = this.token; - if (token === null) return; + + let redeem: () => ReturnType; + if (this.mode === "code") { + const code = this.code; + if (code === null) return; + redeem = () => redeemSteamLinkCode(code); + } else { + const token = this.token; + if (token === null) return; + redeem = () => redeemSteamLink(token); + } const myRequestId = this.requestId; this.redeemState = "redeeming"; this.failureReason = null; + this.retryAfterSeconds = null; this.requestUpdate(); - const result = await redeemSteamLink(token); + const result = await redeem(); if (myRequestId !== this.requestId) return; // closed/reopened meanwhile if (result.ok) { @@ -152,6 +266,7 @@ export class SteamLinkModal extends BaseModal { } else { this.redeemState = "failed"; this.failureReason = result.reason; + this.retryAfterSeconds = result.retryAfterSeconds ?? null; } this.requestUpdate(); } @@ -189,6 +304,42 @@ export class SteamLinkModal extends BaseModal { `; } + if (this.loadState === "code_entry") { + return html` +
+

+ ${translateText("steam_link_modal.code_prompt")} +

+ this.handleCodeInput(e)} + /> + ${this.codeError + ? html`

+ ${this.codeError} +

` + : null} +
+ + +
+
+ `; + } + const ready = this.loadState === "ready"; const persona = ready ? (this.personaName ?? translateText("steam_link_modal.unknown_persona")) @@ -210,14 +361,26 @@ export class SteamLinkModal extends BaseModal { ? translateText("steam_link_modal.linking") : translateText("steam_link_modal.confirm"); + // "rate_limited" is the one reason whose message takes a parameter + // (Retry-After's seconds, when the server sent one) — every other reason + // is a fixed string, so only this one needs the params argument at all. + const failureMessage = () => { + const reasonKey = + REASON_KEYS[this.failureReason ?? ""] ?? DEFAULT_REASON_KEY; + if (this.failureReason === "rate_limited") { + return translateText(reasonKey, { + seconds: this.retryAfterSeconds ?? 0, + }); + } + return translateText(reasonKey); + }; + return html`

${prompt}

${this.redeemState === "failed" ? html`

- ${translateText( - REASON_KEYS[this.failureReason ?? ""] ?? DEFAULT_REASON_KEY, - )} + ${failureMessage()}

` : null}
diff --git a/tests/client/SteamLink.test.ts b/tests/client/SteamLink.test.ts index f782ad38b1..92227408f7 100644 --- a/tests/client/SteamLink.test.ts +++ b/tests/client/SteamLink.test.ts @@ -16,15 +16,24 @@ vi.mock("../../src/client/Auth", () => ({ import { getAuthHeader, logOut } from "../../src/client/Auth"; import { fetchSteamLinkTicket, + isSteamLinkHash, + isValidSteamLinkCode, + normalizeSteamLinkCode, parseSteamLinkToken, redeemSteamLink, + redeemSteamLinkCode, stashPendingLink, takePendingLink, } from "../../src/client/SteamLink"; -const res = (body: unknown, status = 200) => ({ +const res = ( + body: unknown, + status = 200, + headers: Record = {}, +) => ({ status, json: async () => body, + headers: { get: (name: string) => headers[name] ?? null }, }); const fetchMock = () => global.fetch as ReturnType; @@ -53,6 +62,58 @@ describe("parseSteamLinkToken", () => { }); }); +describe("isSteamLinkHash", () => { + it("is true for the token-carrying hash", () => { + expect(isSteamLinkHash("#steam-link?token=abc123")).toBe(true); + }); + it("is true for the bare hash (the code-entry fallback destination)", () => { + expect(isSteamLinkHash("#steam-link")).toBe(true); + }); + it("is false for unrelated hashes", () => { + expect(isSteamLinkHash("#token-login?token-login=x")).toBe(false); + expect(isSteamLinkHash("")).toBe(false); + // Must not fuzzy-match a hash that merely starts similarly. + expect(isSteamLinkHash("#steam-linked-something")).toBe(false); + }); +}); + +describe("normalizeSteamLinkCode", () => { + it("uppercases, trims surrounding whitespace, and strips the presentation hyphen", () => { + expect(normalizeSteamLinkCode(" abcd-2345 ")).toBe("ABCD2345"); + }); + it("is a no-op on an already-normalized code", () => { + expect(normalizeSteamLinkCode("ABCD2345")).toBe("ABCD2345"); + }); + it("does not remap ambiguous characters (0/O, 1/I/L, U are not in the alphabet)", () => { + // These are deliberately NOT corrected to their look-alikes — a code + // containing one is malformed, not a typo to guess at. + expect(normalizeSteamLinkCode("0OIL1U23")).toBe("0OIL1U23"); + }); +}); + +describe("isValidSteamLinkCode", () => { + it("accepts an 8-character code drawn from the fixed alphabet", () => { + expect(isValidSteamLinkCode("23456789")).toBe(true); + expect(isValidSteamLinkCode("ABCDEFGH")).toBe(true); + }); + it("rejects a code containing an excluded ambiguous character", () => { + expect(isValidSteamLinkCode("2345678O")).toBe(false); + expect(isValidSteamLinkCode("2345678I")).toBe(false); + expect(isValidSteamLinkCode("2345678L")).toBe(false); + expect(isValidSteamLinkCode("2345678U")).toBe(false); + expect(isValidSteamLinkCode("23456780")).toBe(false); + expect(isValidSteamLinkCode("23456781")).toBe(false); + }); + it("rejects the wrong length", () => { + expect(isValidSteamLinkCode("2345678")).toBe(false); // 7 + expect(isValidSteamLinkCode("234567899")).toBe(false); // 9 + expect(isValidSteamLinkCode("")).toBe(false); + }); + it("rejects lower case (validation runs after normalization, not instead of it)", () => { + expect(isValidSteamLinkCode("abcdefgh")).toBe(false); + }); +}); + describe("pending link stash", () => { it("survives a round trip and is consumed once", () => { stashPendingLink("abc"); @@ -146,6 +207,78 @@ describe("redeemSteamLink", () => { expect(result.ok).toBe(false); }); + + // 429 must never collapse into "failed": the throttle refuses even a + // correct token/code once tripped, so "that was wrong" would be actively + // misleading. Distinguishing it is the point of this task. + it("maps 429 to a distinct 'rate_limited' reason with the parsed Retry-After seconds", async () => { + fetchMock().mockResolvedValueOnce(res({}, 429, { "Retry-After": "30" })); + + const result = await redeemSteamLink("tok123"); + + expect(result).toEqual({ + ok: false, + reason: "rate_limited", + retryAfterSeconds: 30, + }); + }); + + it("degrades to a null retryAfterSeconds when the Retry-After header is absent", async () => { + fetchMock().mockResolvedValueOnce(res({}, 429)); + + const result = await redeemSteamLink("tok123"); + + expect(result).toEqual({ + ok: false, + reason: "rate_limited", + retryAfterSeconds: null, + }); + }); + + it("still maps unrelated failures (e.g. 500) to 'failed', not 'rate_limited'", async () => { + fetchMock().mockResolvedValueOnce(res({}, 500)); + + const result = await redeemSteamLink("tok123"); + + expect(result).toEqual({ ok: false, reason: "failed" }); + }); +}); + +describe("redeemSteamLinkCode", () => { + it("posts { code } (not { token }) to the same redeem endpoint", async () => { + fetchMock().mockResolvedValueOnce(res({}, 200)); + + const result = await redeemSteamLinkCode("ABCD2345"); + + expect(result).toEqual({ ok: true }); + const [url, init] = fetchMock().mock.calls[0]; + expect(String(url)).toContain("/auth/steam/link"); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + code: "ABCD2345", + }); + }); + + it("surfaces the server's 409 reason verbatim, same as the token path", async () => { + fetchMock().mockResolvedValueOnce( + res({ reason: "steam_has_progress" }, 409), + ); + + const result = await redeemSteamLinkCode("ABCD2345"); + + expect(result).toEqual({ ok: false, reason: "steam_has_progress" }); + }); + + it("maps 429 to 'rate_limited', same as the token path", async () => { + fetchMock().mockResolvedValueOnce(res({}, 429, { "Retry-After": "12" })); + + const result = await redeemSteamLinkCode("ABCD2345"); + + expect(result).toEqual({ + ok: false, + reason: "rate_limited", + retryAfterSeconds: 12, + }); + }); }); describe("fetchSteamLinkTicket", () => { diff --git a/tests/client/SteamLinkModal.test.ts b/tests/client/SteamLinkModal.test.ts index 306b8eb529..5f86e1a4b8 100644 --- a/tests/client/SteamLinkModal.test.ts +++ b/tests/client/SteamLinkModal.test.ts @@ -7,6 +7,7 @@ const isLoggedInMock = vi.hoisted(() => vi.fn()); const stashPendingLinkMock = vi.hoisted(() => vi.fn()); const fetchSteamLinkTicketMock = vi.hoisted(() => vi.fn()); const redeemSteamLinkMock = vi.hoisted(() => vi.fn()); +const redeemSteamLinkCodeMock = vi.hoisted(() => vi.fn()); const getUserMeMock = vi.hoisted(() => vi.fn()); const invalidateUserMeMock = vi.hoisted(() => vi.fn()); @@ -17,11 +18,21 @@ vi.mock("../../src/client/Auth", () => ({ // This is the interface produced by the previous task (SteamLink.ts). Mocking // it here means this file tests the modal's UI/wiring only — parseSteamLinkToken // / redeem status-mapping already has its own coverage in SteamLink.test.ts. -vi.mock("../../src/client/SteamLink", () => ({ - stashPendingLink: stashPendingLinkMock, - fetchSteamLinkTicket: fetchSteamLinkTicketMock, - redeemSteamLink: redeemSteamLinkMock, -})); +// normalizeSteamLinkCode/isValidSteamLinkCode are kept real (via importActual) +// since they're pure and already covered there — re-mocking them here would +// just be reimplementing them a second time in this file. +vi.mock("../../src/client/SteamLink", async (importOriginal) => { + const actual = + await importOriginal(); + return { + normalizeSteamLinkCode: actual.normalizeSteamLinkCode, + isValidSteamLinkCode: actual.isValidSteamLinkCode, + stashPendingLink: stashPendingLinkMock, + fetchSteamLinkTicket: fetchSteamLinkTicketMock, + redeemSteamLink: redeemSteamLinkMock, + redeemSteamLinkCode: redeemSteamLinkCodeMock, + }; +}); vi.mock("../../src/client/Api", () => ({ getUserMe: getUserMeMock, @@ -237,4 +248,147 @@ describe("SteamLinkModal", () => { expect(redeemSteamLinkMock).toHaveBeenCalledWith("tok-abc"); expect(invalidateUserMeMock).toHaveBeenCalled(); }); + + // ─── Code-entry path (Task 17) ────────────────────────────────────────── + // The desktop gate's fallback: when the browser handoff itself fails, it + // shows an 8-character code instead of opening a token-carrying URL. There + // is no ticket to look up a Steam persona from for this path (see + // SteamLink.ts's fetchSteamLinkTicket doc comment) — these tests pin that + // the confirm step still appears, still names the *web* account, and just + // falls back to the existing "unknown persona" copy instead of a real name. + describe("code entry", () => { + const codeInput = () => + modal.querySelector("input.steam-link-code-input"); + const codeSubmitButton = () => + modal.querySelector( + "button.steam-link-code-submit-btn", + ); + + const typeCode = (raw: string) => { + const input = codeInput(); + expect(input).not.toBeNull(); + input!.value = raw; + input!.dispatchEvent(new Event("input", { bubbles: true })); + }; + + it("when not logged in, routes to the login flow instead of showing the form", async () => { + isLoggedInMock.mockResolvedValue(false); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + expect(window.location.hash).toBe("#modal=account"); + expect(modal.isOpen()).toBe(false); + expect(getUserMeMock).not.toHaveBeenCalled(); + expect(fetchSteamLinkTicketMock).not.toHaveBeenCalled(); + expect(redeemSteamLinkCodeMock).not.toHaveBeenCalled(); + }); + + it("shows a code-entry form when logged in", async () => { + isLoggedInMock.mockResolvedValue(true); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + expect(modal.isOpen()).toBe(true); + expect(codeInput()).not.toBeNull(); + expect(codeSubmitButton()).not.toBeNull(); + }); + + it("rejects a malformed code inline, with no network call at all", async () => { + isLoggedInMock.mockResolvedValue(true); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + // Contains 'O', which the fixed alphabet deliberately excludes. + typeCode("2345678O"); + codeSubmitButton()?.click(); + await modal.updateComplete; + + expect(modal.textContent).toContain("steam_link_modal.invalid_code"); + expect(getUserMeMock).not.toHaveBeenCalled(); + expect(redeemSteamLinkCodeMock).not.toHaveBeenCalled(); + }); + + it("normalizes a well-formed code (lower case, spaces, hyphen) and proceeds to confirm, showing the web account but no persona", async () => { + isLoggedInMock.mockResolvedValue(true); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + typeCode(" abcd-efgh "); + codeSubmitButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + // No ticket lookup exists for a code — the persona side of the prompt + // must fall back to the same "unknown persona" copy the token path + // uses when Steam declines to resolve a name, not a blank/crash. + expect(fetchSteamLinkTicketMock).not.toHaveBeenCalled(); + expect(modal.textContent).toContain("steam_link_modal.unknown_persona"); + expect(modal.textContent).toContain("web.1234"); + }); + + it("posts the normalized code, not the raw typed value, to redeemSteamLinkCode on confirm", async () => { + isLoggedInMock.mockResolvedValue(true); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + redeemSteamLinkCodeMock.mockResolvedValue({ ok: true }); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + typeCode(" abcd-efgh "); + codeSubmitButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + confirmButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.textContent).toContain("steam_link_modal.success"); + }); + expect(redeemSteamLinkCodeMock).toHaveBeenCalledWith("ABCDEFGH"); + expect(redeemSteamLinkMock).not.toHaveBeenCalled(); + }); + + it("renders a distinct message for a 429 refusal instead of a generic/wrong-code message", async () => { + isLoggedInMock.mockResolvedValue(true); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + redeemSteamLinkCodeMock.mockResolvedValue({ + ok: false, + reason: "rate_limited", + retryAfterSeconds: 30, + }); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + typeCode("ABCDEFGH"); + codeSubmitButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + confirmButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.textContent).toContain( + "steam_link_modal.reason_rate_limited", + ); + }); + expect(modal.textContent).not.toContain("steam_link_modal.reason_failed"); + }); + }); }); From fa61024f153102bebd835e0c6a6dc246d8d5071b Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Mon, 3 Aug 2026 16:12:48 +0100 Subject: [PATCH 6/8] fix(steam-link): resume a code-entry intent across login, fix confirm copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the code-entry form: - openForCodeEntry() checked login before rendering the form, but nothing survived the redirect to log in — a logged-out player arriving by code was bounced to the account modal and lost the flow entirely, with no way back except retyping a hash nothing on the page links to. Gives the pending-link stash an explicit kind discriminator ("token" | "code_entry") so a stashed code-entry intent can't be confused with a stashed token, and adds resumePendingSteamLink() as the one place both get read back and resumed after login. takePendingLink() also now degrades to null instead of throwing on the old bare-string stash format. - The confirm prompt's generic template ("Link Steam {persona} with account {username}?") read as a doubled "Steam ... Steam account" whenever there is no persona to show - always true on the code path, since no ticket lookup exists for a code. Adds a dedicated confirm_prompt_no_persona string instead of forcing one template to cover both cases, and removes the now-unreferenced unknown_persona key. - Adds Enter-to-submit on the code input. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JRMRzHbZp2VhxbxqDMw4Zr --- resources/lang/en.json | 2 +- src/client/Main.ts | 21 ++++--- src/client/SteamLink.ts | 87 ++++++++++++++++++++++++++--- src/client/SteamLinkModal.ts | 50 ++++++++++++----- tests/client/SteamLink.test.ts | 73 +++++++++++++++++++++++- tests/client/SteamLinkModal.test.ts | 46 +++++++++++++-- 6 files changed, 237 insertions(+), 42 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 17abd5cf28..dc2dffde7b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1452,6 +1452,7 @@ "code_submit": "Continue", "confirm": "Link Account", "confirm_prompt": "Link Steam {persona} with account {username}?", + "confirm_prompt_no_persona": "Link your Steam account with account {username}?", "invalid_code": "That doesn't look like a valid code. Check it and try again.", "linking": "Linking…", "load_error": "Couldn't load your account details. Please try again from Steam.", @@ -1464,7 +1465,6 @@ "reason_steam_linked_elsewhere": "This Steam account is already linked to a different OpenFront account.", "success": "Your Steam account is now linked.", "title": "Link Steam Account", - "unknown_persona": "your Steam account", "unknown_username": "your account" }, "steam_user_header": { diff --git a/src/client/Main.ts b/src/client/Main.ts index 9146dd5150..aa42c291cf 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -57,7 +57,7 @@ import "./SinglePlayerModal"; import { isSteamLinkHash, parseSteamLinkToken, - takePendingLink, + resumePendingSteamLink, } from "./SteamLink"; import "./SteamLinkModal"; import { SteamLinkModal } from "./SteamLinkModal"; @@ -463,17 +463,16 @@ class Client { "Sharing this ID will allow others to view your game history and stats.", ); - // Resume a Steam-link confirmation that was interrupted by a login - // redirect (Discord/Google OAuth, magic link): the modal stashed the - // token and sent the player to log in via #modal=account, so a login - // redirect commonly lands back there rather than on a clean "/" — - // this must NOT be gated on cleanHomepage below. Only read the stash - // once login is confirmed: takePendingLink() consumes it on read, so - // a speculative read while logged out would burn a token that a + // Resume a Steam-link flow that was interrupted by a login redirect + // (Discord/Google OAuth, magic link): the modal stashed either a + // token or a bare code-entry intent and sent the player to log in + // via #modal=account, so a login redirect commonly lands back there + // rather than on a clean "/" — this must NOT be gated on + // cleanHomepage below. Only resume once login is confirmed: + // resumePendingSteamLink() consumes the stash on read, so a + // speculative call while logged out would burn an entry that a // *later* successful login should still get to resume. - const pendingLink = takePendingLink(); - if (pendingLink) { - void this.steamLinkModal?.openWithToken(pendingLink); + if (resumePendingSteamLink(this.steamLinkModal)) { return; } diff --git a/src/client/SteamLink.ts b/src/client/SteamLink.ts index 43dd01f471..0543c1fbb9 100644 --- a/src/client/SteamLink.ts +++ b/src/client/SteamLink.ts @@ -61,20 +61,93 @@ export function isValidSteamLinkCode(code: string): boolean { ); } +// What's stashed across a login redirect: either a token (from the browser +// handoff) or a bare intent to resume the code-entry form (there is no code +// to preserve — the player hadn't typed one yet when they were sent to log +// in). One localStorage slot, one flow in flight at a time, so the two are +// tagged rather than left to be told apart by shape — a stashed code-entry +// intent must never be mistaken for a token, or vice versa. +export type PendingLink = + | { kind: "token"; token: string } + | { kind: "code_entry" }; + // The token often needs to survive a login (magic link, Discord/Google OAuth // redirect) before it can be redeemed against an authenticated account, so it // is stashed in localStorage rather than held in memory. export function stashPendingLink(token: string): void { - localStorage.setItem(PENDING_LINK_KEY, token); + localStorage.setItem( + PENDING_LINK_KEY, + JSON.stringify({ kind: "token", token }), + ); +} + +// The code-entry form has nothing to preserve but the fact that the player +// was on it — they're sent to log in before typing anything, so there's no +// draft/code value to carry across. See SteamLinkModal.openForCodeEntry(). +export function stashPendingCodeEntry(): void { + localStorage.setItem( + PENDING_LINK_KEY, + JSON.stringify({ kind: "code_entry" }), + ); } -// Consumed on read: once taken, a stale/already-handled token can't re-fire -// on a later page load. -export function takePendingLink(): string | null { - const token = localStorage.getItem(PENDING_LINK_KEY); - if (token === null) return null; +// Consumed on read: once taken, a stale/already-handled entry can't re-fire +// on a later page load. Malformed/legacy storage (this used to hold a bare, +// unquoted token string before the kind discriminator existed) degrades to +// null rather than throwing — a leftover value from before this change must +// not crash every subsequent page load for whoever still has one. +export function takePendingLink(): PendingLink | null { + const raw = localStorage.getItem(PENDING_LINK_KEY); + if (raw === null) return null; localStorage.removeItem(PENDING_LINK_KEY); - return token; + + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null && "kind" in parsed) { + const candidate = parsed as { kind: unknown; token?: unknown }; + if (candidate.kind === "token" && typeof candidate.token === "string") { + return { kind: "token", token: candidate.token }; + } + if (candidate.kind === "code_entry") { + return { kind: "code_entry" }; + } + } + } catch { + // Legacy raw-string format, or otherwise unparsable — fall through to + // null below rather than throwing. + } + return null; +} + +// The subset of SteamLinkModal's public surface resumePendingSteamLink needs +// — kept as a small structural interface (rather than importing the modal +// class itself) so this stays a plain, easily-unit-testable function with no +// dependency on Lit/the DOM. +export interface PendingLinkModal { + openWithToken(token: string): Promise; + openForCodeEntry(): Promise; +} + +// Resumes a Steam-link flow that was interrupted by a login redirect +// (Discord/Google OAuth, magic link, or the account modal's own login form) +// — the one place both openWithToken's and openForCodeEntry's stashes get +// read back. Returns true when something was resumed (callers should treat +// that as "handled, stop routing further for this pass"), false when there +// was nothing pending. A missing modal (element not found/not yet defined) +// still consumes the stash — same "don't replay a stale entry" reasoning as +// takePendingLink itself — it just has nothing to hand the result to. +export function resumePendingSteamLink( + modal: PendingLinkModal | undefined, +): boolean { + const pending = takePendingLink(); + if (pending === null) return false; + + if (pending.kind === "token") { + void modal?.openWithToken(pending.token); + } else { + void modal?.openForCodeEntry(); + } + return true; } export type SteamLinkTicketResult = diff --git a/src/client/SteamLinkModal.ts b/src/client/SteamLinkModal.ts index 06bbc44476..9122a3392c 100644 --- a/src/client/SteamLinkModal.ts +++ b/src/client/SteamLinkModal.ts @@ -10,6 +10,7 @@ import { normalizeSteamLinkCode, redeemSteamLink, redeemSteamLinkCode, + stashPendingCodeEntry, stashPendingLink, } from "./SteamLink"; import { translateText } from "./Utils"; @@ -70,8 +71,10 @@ const BUTTON_BASE = * possible either — there is nothing that resolves a persona from a code * alone, and this component does not invent one. The confirm step still * appears (still names the *web* account from /users/@me, still requires an - * explicit click), it just falls back to the same "unknown persona" copy - * already used when Steam itself declines to resolve a name. + * explicit click); the prompt just uses a dedicated no-persona phrasing + * (steam_link_modal.confirm_prompt_no_persona) rather than filling the + * generic template's {persona} slot with a placeholder noun, which reads as + * a doubled "Steam ... Steam account". */ @customElement("steam-link-modal") export class SteamLinkModal extends BaseModal { @@ -130,13 +133,16 @@ export class SteamLinkModal extends BaseModal { // Entry point for the fallback code (see the class doc comment above). // Same login precondition as openWithToken and for the same reason: the - // confirm step needs the logged-in account's name. Unlike a token, a - // not-yet-submitted code isn't stashed across the login redirect — there's - // nothing typed yet to preserve, so the player just re-opens this after - // logging in and re-enters it. That's a minor inconvenience (a few - // keystrokes), not a lost flow. + // confirm step needs the logged-in account's name. Unlike a token, there's + // no code to preserve yet (the player hasn't typed one) — but the *intent* + // to resume the code-entry form still has to survive the login redirect, + // or the whole point of this fallback (a route through when the browser + // handoff fails) evaporates on the one step most likely to interrupt it. + // See SteamLink.ts's stashPendingCodeEntry/resumePendingSteamLink and + // Main.ts's onUserMe for the resume side of this. public async openForCodeEntry(): Promise { if (!(await isLoggedIn())) { + stashPendingCodeEntry(); window.location.hash = "modal=account"; return; } @@ -216,7 +222,7 @@ export class SteamLinkModal extends BaseModal { // No ticket to fetch for a code (see the class doc comment) — just the // logged-in account's name. personaName stays null, which the ready-state - // render already falls back to "unknown_persona" for. + // render below renders via the dedicated no-persona prompt. void getUserMe().then((userMe) => { if (myRequestId !== this.requestId) return; // superseded if (userMe === false) { @@ -316,6 +322,9 @@ export class SteamLinkModal extends BaseModal { placeholder=${translateText("steam_link_modal.code_placeholder")} .value=${this.codeDraft} @input=${(e: Event) => this.handleCodeInput(e)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === "Enter") this.handleCodeSubmit(); + }} /> ${this.codeError ? html`

@@ -341,17 +350,28 @@ export class SteamLinkModal extends BaseModal { } const ready = this.loadState === "ready"; - const persona = ready - ? (this.personaName ?? translateText("steam_link_modal.unknown_persona")) - : translateText("steam_link_modal.loading_placeholder"); const account = ready ? (this.username ?? translateText("steam_link_modal.unknown_username")) : translateText("steam_link_modal.loading_placeholder"); - const prompt = translateText("steam_link_modal.confirm_prompt", { - persona, - username: account, - }); + // Once ready, a null personaName means there is genuinely no Steam name + // to show — always true for the code path (no ticket lookup exists for + // a code), and rarely also true for the token path when Steam itself + // declines to resolve one. Filling the generic template's {persona} slot + // with a placeholder noun in that case reads as "Link Steam your Steam + // account with account ..." — a doubled "Steam" — so it gets its own + // template instead of trying to make one string serve both cases. + const prompt = + ready && this.personaName === null + ? translateText("steam_link_modal.confirm_prompt_no_persona", { + username: account, + }) + : translateText("steam_link_modal.confirm_prompt", { + persona: ready + ? (this.personaName as string) + : translateText("steam_link_modal.loading_placeholder"), + username: account, + }); // "success" is handled by the early return above — by construction it // can't reach here, so only "redeeming" needs to gate the button. diff --git a/tests/client/SteamLink.test.ts b/tests/client/SteamLink.test.ts index 92227408f7..258e993bda 100644 --- a/tests/client/SteamLink.test.ts +++ b/tests/client/SteamLink.test.ts @@ -22,6 +22,8 @@ import { parseSteamLinkToken, redeemSteamLink, redeemSteamLinkCode, + resumePendingSteamLink, + stashPendingCodeEntry, stashPendingLink, takePendingLink, } from "../../src/client/SteamLink"; @@ -115,9 +117,76 @@ describe("isValidSteamLinkCode", () => { }); describe("pending link stash", () => { - it("survives a round trip and is consumed once", () => { + // Both a token and a bare "the player was mid-code-entry" intent share one + // storage slot (only one linking flow can be in flight at a time), so the + // stashed value carries an explicit `kind` — a resumed code-entry intent + // must never be mistaken for a token, or vice versa. + it("stashes and resumes a token, consumed once", () => { stashPendingLink("abc"); - expect(takePendingLink()).toBe("abc"); + expect(takePendingLink()).toEqual({ kind: "token", token: "abc" }); + expect(takePendingLink()).toBeNull(); + }); + + it("stashes and resumes a code-entry intent, consumed once", () => { + stashPendingCodeEntry(); + expect(takePendingLink()).toEqual({ kind: "code_entry" }); + expect(takePendingLink()).toBeNull(); + }); + + it("returns null for a legacy raw-string stash instead of throwing", () => { + // Pre-migration format: stashPendingLink used to store the token as a + // bare (unquoted) string. A tab still holding one of those across this + // change must degrade safely, not crash takePendingLink for everyone. + localStorage.setItem("steam-link-pending", "tok-abc"); + expect(() => takePendingLink()).not.toThrow(); + expect(takePendingLink()).toBeNull(); + }); +}); + +describe("resumePendingSteamLink", () => { + const makeModal = () => ({ + openWithToken: vi.fn(async () => {}), + openForCodeEntry: vi.fn(async () => {}), + }); + + it("resumes a stashed token via openWithToken", () => { + stashPendingLink("tok-abc"); + const modal = makeModal(); + + const resumed = resumePendingSteamLink(modal); + + expect(resumed).toBe(true); + expect(modal.openWithToken).toHaveBeenCalledWith("tok-abc"); + expect(modal.openForCodeEntry).not.toHaveBeenCalled(); + }); + + it("resumes a stashed code-entry intent via openForCodeEntry — the case that lands a logged-out #steam-link arrival back on the code form after login", () => { + stashPendingCodeEntry(); + const modal = makeModal(); + + const resumed = resumePendingSteamLink(modal); + + expect(resumed).toBe(true); + expect(modal.openForCodeEntry).toHaveBeenCalledTimes(1); + expect(modal.openWithToken).not.toHaveBeenCalled(); + }); + + it("returns false and calls nothing when there is no pending link", () => { + const modal = makeModal(); + + const resumed = resumePendingSteamLink(modal); + + expect(resumed).toBe(false); + expect(modal.openWithToken).not.toHaveBeenCalled(); + expect(modal.openForCodeEntry).not.toHaveBeenCalled(); + }); + + it("is a no-op (returns false) when the modal element isn't present", () => { + stashPendingCodeEntry(); + // Main.ts guards with `this.steamLinkModal?.` — mirror that here: a + // missing modal must not throw, and the stash is still consumed so a + // later page load can't replay it. + expect(() => resumePendingSteamLink(undefined)).not.toThrow(); expect(takePendingLink()).toBeNull(); }); }); diff --git a/tests/client/SteamLinkModal.test.ts b/tests/client/SteamLinkModal.test.ts index 5f86e1a4b8..0d48072521 100644 --- a/tests/client/SteamLinkModal.test.ts +++ b/tests/client/SteamLinkModal.test.ts @@ -5,6 +5,7 @@ import type { UserMeResponse } from "../../src/core/ApiSchemas"; const isLoggedInMock = vi.hoisted(() => vi.fn()); const stashPendingLinkMock = vi.hoisted(() => vi.fn()); +const stashPendingCodeEntryMock = vi.hoisted(() => vi.fn()); const fetchSteamLinkTicketMock = vi.hoisted(() => vi.fn()); const redeemSteamLinkMock = vi.hoisted(() => vi.fn()); const redeemSteamLinkCodeMock = vi.hoisted(() => vi.fn()); @@ -28,6 +29,7 @@ vi.mock("../../src/client/SteamLink", async (importOriginal) => { normalizeSteamLinkCode: actual.normalizeSteamLinkCode, isValidSteamLinkCode: actual.isValidSteamLinkCode, stashPendingLink: stashPendingLinkMock, + stashPendingCodeEntry: stashPendingCodeEntryMock, fetchSteamLinkTicket: fetchSteamLinkTicketMock, redeemSteamLink: redeemSteamLinkMock, redeemSteamLinkCode: redeemSteamLinkCodeMock, @@ -271,12 +273,18 @@ describe("SteamLinkModal", () => { input!.dispatchEvent(new Event("input", { bubbles: true })); }; - it("when not logged in, routes to the login flow instead of showing the form", async () => { + it("when not logged in, stashes a code-entry intent (not a token) and routes to the login flow", async () => { isLoggedInMock.mockResolvedValue(false); await modal.openForCodeEntry(); await modal.updateComplete; + // The intent (not a code — nothing's been typed yet) must survive the + // login redirect so the player lands back on this form afterward, + // rather than the flow silently evaporating. See SteamLink.test.ts's + // resumePendingSteamLink suite for the resume side of this. + expect(stashPendingCodeEntryMock).toHaveBeenCalledTimes(1); + expect(stashPendingLinkMock).not.toHaveBeenCalled(); expect(window.location.hash).toBe("#modal=account"); expect(modal.isOpen()).toBe(false); expect(getUserMeMock).not.toHaveBeenCalled(); @@ -311,7 +319,7 @@ describe("SteamLinkModal", () => { expect(redeemSteamLinkCodeMock).not.toHaveBeenCalled(); }); - it("normalizes a well-formed code (lower case, spaces, hyphen) and proceeds to confirm, showing the web account but no persona", async () => { + it("normalizes a well-formed code (lower case, spaces, hyphen) and proceeds to confirm, showing the web account with the dedicated no-persona prompt", async () => { isLoggedInMock.mockResolvedValue(true); getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); @@ -326,14 +334,40 @@ describe("SteamLinkModal", () => { expect(confirmButton()?.disabled).toBe(false); }); - // No ticket lookup exists for a code — the persona side of the prompt - // must fall back to the same "unknown persona" copy the token path - // uses when Steam declines to resolve a name, not a blank/crash. + // No ticket lookup exists for a code, so there's never a persona name + // to show on this path — it must use the dedicated no-persona prompt + // key (not the generic "Link Steam {persona} with account {username}" + // template with a filled-in placeholder, which reads as a doubled + // "Steam ... Steam account"). expect(fetchSteamLinkTicketMock).not.toHaveBeenCalled(); - expect(modal.textContent).toContain("steam_link_modal.unknown_persona"); + expect(modal.textContent).toContain( + "steam_link_modal.confirm_prompt_no_persona", + ); + expect(modal.textContent).not.toContain( + "steam_link_modal.confirm_prompt:", + ); expect(modal.textContent).toContain("web.1234"); }); + it("pressing Enter in the code field submits it, same as clicking Continue", async () => { + isLoggedInMock.mockResolvedValue(true); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + typeCode("ABCDEFGH"); + codeInput()?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + expect(getUserMeMock).toHaveBeenCalledTimes(1); + }); + it("posts the normalized code, not the raw typed value, to redeemSteamLinkCode on confirm", async () => { isLoggedInMock.mockResolvedValue(true); getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); From 43d4f97b69eb96d05577133b70f13b29fd6206ee Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Mon, 3 Aug 2026 16:50:26 +0100 Subject: [PATCH 7/8] fix(steam-link): identify the account by publicId, fix the code-refusal dead end Whole-branch review findings: - The confirm prompt fell back to a placeholder noun ("your account") whenever player.username was null - which is the default, unclaimed state, not an edge case. That guts the confirm step's whole purpose (a shared machine's browser could be logged into someone else's session) and repeats the same doubled-noun copy bug just fixed on the persona side. Follows the repo's existing username ?? publicId convention (ApiSchemas.ts, PlayerName.ts) instead, and drops the now-unreferenced unknown_username key. - A refused code (the alphabet still has eye-confusable pairs like B/8, S/5) landed on a confirm screen with only Cancel, which closes the modal for good since Main.ts's strip() already removed #steam-link from the URL - the same dead end this task exists to remove, one screen further along. A code-mode refusal now returns to the code-entry form with the draft still prefilled and the refusal explained inline, so a mistranscribed character can be corrected and resubmitted without reopening the modal. - Minor: the load-error copy told a code-path player to "try again from Steam", which is the token path's instruction. Gives the code path its own message. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JRMRzHbZp2VhxbxqDMw4Zr --- resources/lang/en.json | 4 +- src/client/SteamLinkModal.ts | 85 +++++++++++++----- tests/client/SteamLinkModal.test.ts | 133 +++++++++++++++++++++++++++- 3 files changed, 198 insertions(+), 24 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index dc2dffde7b..2d0b407b90 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1456,6 +1456,7 @@ "invalid_code": "That doesn't look like a valid code. Check it and try again.", "linking": "Linking…", "load_error": "Couldn't load your account details. Please try again from Steam.", + "load_error_code": "Couldn't load your account details. Please try again.", "loading_placeholder": "…", "reason_account_already_has_steam": "Your account is already linked to a different Steam account.", "reason_expired": "This link has expired. Please try again from Steam.", @@ -1464,8 +1465,7 @@ "reason_steam_has_progress": "This Steam account already has its own progress and can't be merged.", "reason_steam_linked_elsewhere": "This Steam account is already linked to a different OpenFront account.", "success": "Your Steam account is now linked.", - "title": "Link Steam Account", - "unknown_username": "your account" + "title": "Link Steam Account" }, "steam_user_header": { "avatar_alt": "Steam avatar", diff --git a/src/client/SteamLinkModal.ts b/src/client/SteamLinkModal.ts index 9122a3392c..783f47e537 100644 --- a/src/client/SteamLinkModal.ts +++ b/src/client/SteamLinkModal.ts @@ -43,6 +43,22 @@ const REASON_KEYS: Record = { }; const DEFAULT_REASON_KEY = "steam_link_modal.reason_failed"; +// "rate_limited" is the one reason whose message takes a parameter +// (Retry-After's seconds, when the server sent one) — every other reason is +// a fixed string, so only this one needs the params argument at all. +// Module-level (not a method) so both the ready-state render and the +// code-mode failure handling in handleConfirm can reuse it. +function reasonMessage( + reason: string | null, + retryAfterSeconds: number | null, +): string { + const reasonKey = REASON_KEYS[reason ?? ""] ?? DEFAULT_REASON_KEY; + if (reason === "rate_limited") { + return translateText(reasonKey, { seconds: retryAfterSeconds ?? 0 }); + } + return translateText(reasonKey); +} + const BUTTON_BASE = "flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl " + "transition-all disabled:opacity-50 disabled:pointer-events-none border-0"; @@ -183,7 +199,13 @@ export class SteamLinkModal extends BaseModal { return; } this.personaName = ticket.personaName; - this.username = userMe.player.username ?? null; + // player.username is null for anyone who has never claimed one — the + // default state, not an edge case (usernameStatus starts + // "unclaimed"). Falling back to a placeholder noun there would gut + // the whole point of this screen: identifying which web account is + // about to be linked. Follows the repo-wide `username ?? publicId` + // convention (ApiSchemas.ts, PlayerName.ts) instead. + this.username = userMe.player.username ?? userMe.player.publicId; this.loadState = "ready"; this.requestUpdate(); }, @@ -231,7 +253,9 @@ export class SteamLinkModal extends BaseModal { return; } this.personaName = null; - this.username = userMe.player.username ?? null; + // See the same fallback in onOpen() above — publicId, never a + // placeholder noun, when the account has no claimed username yet. + this.username = userMe.player.username ?? userMe.player.publicId; this.loadState = "ready"; this.requestUpdate(); }); @@ -269,6 +293,26 @@ export class SteamLinkModal extends BaseModal { if (result.ok) { invalidateUserMe(); this.redeemState = "success"; + } else if (this.mode === "code") { + // A refused code has nothing left to fix on this confirm screen — + // Confirm would just resubmit the exact same, already-refused code. + // The alphabet still has eye-confusable pairs (B/8, S/5, 2/Z, G/6), so + // a one-character mistranscription is a realistic way to land here, + // and the only other action was Cancel — which closes the modal for + // good (Main.ts's strip() already removed #steam-link from the URL, + // so a refresh can't reopen it). Go back to the field instead, with + // the refusal explained inline, so the player can correct a character + // and try again without leaving the modal. Reset the confirm state + // too, so a later, successful resubmission doesn't render this stale + // failure the instant it reaches "ready" again. + this.loadState = "code_entry"; + this.codeError = reasonMessage( + result.reason, + result.retryAfterSeconds ?? null, + ); + this.redeemState = "idle"; + this.failureReason = null; + this.retryAfterSeconds = null; } else { this.redeemState = "failed"; this.failureReason = result.reason; @@ -279,10 +323,19 @@ export class SteamLinkModal extends BaseModal { protected renderBody(): TemplateResult { if (this.loadState === "load_error") { + // The token path's copy ("...try again from Steam") is the wrong + // instruction on the code path — the player is already on the + // website holding a code, not going back through Steam. + const loadErrorMessage = + this.mode === "code" + ? translateText("steam_link_modal.load_error_code") + : translateText("steam_link_modal.load_error"); return html`

-

- ${translateText("steam_link_modal.load_error")} +