diff --git a/index.html b/index.html index 0ba6cbc7d0..e23825acc1 100644 --- a/index.html +++ b/index.html @@ -309,6 +309,7 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index 656c41844a..41e04b5d54 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.", @@ -1444,6 +1445,27 @@ "toggle_achievements": "Toggle achievements", "water_nukes": "Water nukes" }, + "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}?", + "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.", + "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.", + "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.", + "title": "Link Steam Account" + }, "steam_user_header": { "avatar_alt": "Steam avatar", "default_name": "Steam player" diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 1f8bdeb1da..13b4052474 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,44 @@ 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 { + // The bare `void` form swallowed a rejection into an unhandled promise. + // This is an IPC round trip to the Electron main process, so it can + // genuinely reject (no window, a main-process throw); catching keeps the + // failure visible in the console instead of surfacing as a button that + // silently does nothing. + desktopLinkGateBridge() + ?.showLinkGate() + .catch((err) => { + console.error("AccountModal: showLinkGate failed", err); + }); + } + // 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/src/client/Main.ts b/src/client/Main.ts index 89e6477011..21d1ede77d 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -55,6 +55,13 @@ import "./NewsModal"; import "./PlayerProfileModal"; import { RewardsModal } from "./RewardsModal"; import "./SinglePlayerModal"; +import { + isSteamLinkHash, + parseSteamLinkToken, + resumePendingSteamLink, +} from "./SteamLink"; +import "./SteamLinkModal"; +import { SteamLinkModal } from "./SteamLinkModal"; import { StoreModal } from "./Store"; import { TokenLoginModal } from "./TokenLoginModal"; import { @@ -174,6 +181,7 @@ class Client { private tokenLoginModal: TokenLoginModal; private matchmakingModal: MatchmakingModal; private rewardsModal: RewardsModal; + private steamLinkModal: SteamLinkModal; private mostRecentJoinEvent: number; private turnstileTokenPromise: Promise<{ @@ -404,6 +412,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 +463,19 @@ class Client { "Sharing this ID will allow others to view your game history and stats.", ); + // 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. + if (resumePendingSteamLink(this.steamLinkModal)) { + return; + } + // Popups below only on a clean homepage load, never over a deep link // (join URL, #modal=..., #purchase-completed, ...). const cleanHomepage = @@ -750,6 +781,29 @@ 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; + } + + // 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\/([^/]+)/, ); @@ -911,6 +965,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 new file mode 100644 index 0000000000..3e5b1732c3 --- /dev/null +++ b/src/client/SteamLink.ts @@ -0,0 +1,308 @@ +import { getApiBase } from "./Api"; +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 +// 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 LINK_HASH = "#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"); +} + +// 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)) + ); +} + +// 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, + 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 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); + + 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 = + | { 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. +// +// 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 { + 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; retryAfterSeconds?: number | null }; + +// 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 (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 +// (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". +// 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 + // 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: authHeader, + }, + body: JSON.stringify(body), + }); + + if (response.status === 200) { + return { ok: true }; + } + + if (response.status === 401) { + await logOut(); + return { ok: false, reason: "failed" }; + } + + if (response.status === 409) { + const responseBody = await response.json().catch(() => null); + const reason = + typeof responseBody?.reason === "string" + ? responseBody.reason + : "failed"; + return { ok: false, reason }; + } + + if (response.status === 410) { + 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 }; + } + + // Name the shared function and the payload kind, not redeemSteamLink: + // this runs for the fallback-code path too, and logging a code failure + // under the token function's name points anyone triaging it at the + // wrong flow. + console.error( + `postSteamLinkRedeem(${"code" in body ? "code" : "token"}): request failed`, + response.status, + response.statusText, + ); + return { ok: false, reason: "failed" }; + } catch (e) { + console.error( + `postSteamLinkRedeem(${"code" in body ? "code" : "token"}): request failed`, + e, + ); + 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 new file mode 100644 index 0000000000..783f47e537 --- /dev/null +++ b/src/client/SteamLinkModal.ts @@ -0,0 +1,470 @@ +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, + isValidSteamLinkCode, + normalizeSteamLinkCode, + redeemSteamLink, + redeemSteamLinkCode, + stashPendingCodeEntry, + stashPendingLink, +} from "./SteamLink"; +import { translateText } from "./Utils"; + +// "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/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"; + +// "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"; + +/** + * 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. + * + * 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); 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 { + 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). + 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.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, 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; + } + this.mode = "code"; + this.token = null; + this.open(); + } + + protected onOpen(): void { + const myRequestId = ++this.requestId; + 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"; + 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; + // 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(); + }, + ); + } + + 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 below renders via the dedicated no-persona prompt. + void getUserMe().then((userMe) => { + if (myRequestId !== this.requestId) return; // superseded + if (userMe === false) { + this.loadState = "load_error"; + this.requestUpdate(); + return; + } + this.personaName = 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(); + }); + } + + private async handleConfirm(): Promise { + if ( + this.loadState !== "ready" || + this.redeemState === "redeeming" || + this.redeemState === "success" + ) { + 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 redeem(); + if (myRequestId !== this.requestId) return; // closed/reopened meanwhile + + 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; + this.retryAfterSeconds = result.retryAfterSeconds ?? null; + } + this.requestUpdate(); + } + + 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` +
+ + +
+ `; + } + + if (this.redeemState === "success") { + return html` +
+

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

+ +
+ `; + } + + if (this.loadState === "code_entry") { + return html` +
+

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

+ this.handleCodeInput(e)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === "Enter") this.handleCodeSubmit(); + }} + /> + ${this.codeError + ? html`

+ ${this.codeError} +

` + : null} +
+ + +
+
+ `; + } + + const ready = this.loadState === "ready"; + // this.username is only ever set from userMe.player.username ?? publicId + // (see onOpen/handleCodeSubmit) — always a real, identifying string once + // ready. The `?? ""` here is just a defensive TS-null guard, never + // expected to actually render; there is deliberately no "unknown + // account" placeholder text, since a shared-machine confirm screen that + // can't name the account defeats the point of asking at all. + const account = ready + ? (this.username ?? "") + : translateText("steam_link_modal.loading_placeholder"); + + // 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. + 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`

+ ${reasonMessage(this.failureReason, this.retryAfterSeconds)} +

` + : null} +
+ + +
+
+ `; + } +} 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(); + }); + }); }); diff --git a/tests/client/SteamLink.test.ts b/tests/client/SteamLink.test.ts new file mode 100644 index 0000000000..7dffcd01ee --- /dev/null +++ b/tests/client/SteamLink.test.ts @@ -0,0 +1,401 @@ +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 { getAuthHeader, logOut } from "../../src/client/Auth"; +import { + fetchSteamLinkTicket, + isSteamLinkHash, + isValidSteamLinkCode, + normalizeSteamLinkCode, + parseSteamLinkToken, + redeemSteamLink, + redeemSteamLinkCode, + resumePendingSteamLink, + stashPendingCodeEntry, + stashPendingLink, + takePendingLink, +} from "../../src/client/SteamLink"; + +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; + +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(() => {}); + // 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", () => { + 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("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", () => { + // 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()).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("consumes the stash and still returns true 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. + // + // TRUE, not false: the return value means "there was a stash, stop + // routing further this pass", not "a modal was opened". Main.ts + // early-returns on it, and that is still right here — the entry is gone, + // so falling through to other hash handling would act on a flow that no + // longer exists. + expect(resumePendingSteamLink(undefined)).toBe(true); + 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 can + // just call again and get another ok. + it("treats a repeat redemption as ok", async () => { + 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 () => { + 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); + }); + + // 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", () => { + 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..5e5544a742 --- /dev/null +++ b/tests/client/SteamLinkModal.test.ts @@ -0,0 +1,557 @@ +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 stashPendingCodeEntryMock = 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()); + +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. +// 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, + stashPendingCodeEntry: stashPendingCodeEntryMock, + fetchSteamLinkTicket: fetchSteamLinkTicketMock, + redeemSteamLink: redeemSteamLinkMock, + redeemSteamLinkCode: redeemSteamLinkCodeMock, + }; +}); + +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"); + }); + + // player.username is null for any player who has never claimed a username + // — the default state (usernameStatus starts at "unclaimed") — so this is + // the common case, not an edge case. The confirm step exists precisely + // because a shared machine's browser might be logged into someone else's + // OpenFront session; showing a placeholder noun instead of an identifying + // value there guts the whole point of the screen. Follows the repo-wide + // `username ?? publicId` convention (see ApiSchemas.ts / PlayerName.ts). + it("falls back to the account's publicId when the username is unclaimed (null), never a placeholder noun", async () => { + isLoggedInMock.mockResolvedValue(true); + fetchSteamLinkTicketMock.mockResolvedValue({ + ok: true, + personaName: "Ada", + }); + getUserMeMock.mockResolvedValue(makeUserMe(null)); + + await modal.openWithToken("tok-abc"); + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + expect(modal.textContent).toContain("p1"); // publicId from makeUserMe + expect(modal.textContent).not.toContain( + "steam_link_modal.unknown_username", + ); + }); + + 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(); + }); + + // ─── 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 (falling + // back through publicId — see the "identifies the account" tests below — + // never to a placeholder noun), and uses the dedicated no-persona prompt + // rather than a real Steam 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, 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(); + 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 with the dedicated no-persona prompt", 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, 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.confirm_prompt_no_persona", + ); + expect(modal.textContent).not.toContain( + "steam_link_modal.confirm_prompt:", + ); + expect(modal.textContent).toContain("web.1234"); + }); + + it("falls back to the account's publicId when the username is unclaimed (null), never a placeholder noun", async () => { + isLoggedInMock.mockResolvedValue(true); + getUserMeMock.mockResolvedValue(makeUserMe(null)); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + typeCode("ABCDEFGH"); + codeSubmitButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + expect(modal.textContent).toContain("p1"); // publicId from makeUserMe + expect(modal.textContent).not.toContain( + "steam_link_modal.unknown_username", + ); + }); + + it("shows the code-path's own load-error message (not the token path's 'try again from Steam' copy) when /users/@me fails", async () => { + isLoggedInMock.mockResolvedValue(true); + getUserMeMock.mockResolvedValue(false); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + typeCode("ABCDEFGH"); + codeSubmitButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect( + modal + .querySelector(".steam-link-load-error-text") + ?.textContent?.trim(), + ).toBe("steam_link_modal.load_error_code"); + }); + }); + + // Task 17's whole point was giving a code-only player a route through. + // A refusal that strands them on a confirm screen with only 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) recreates that + // same dead end one screen later. 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. + it("returns to the code-entry form, draft still prefilled, after a refused code — and a corrected code can be resubmitted without reopening the modal", async () => { + isLoggedInMock.mockResolvedValue(true); + getUserMeMock.mockResolvedValue(makeUserMe("web.1234")); + redeemSteamLinkCodeMock.mockResolvedValueOnce({ + ok: false, + reason: "failed", + }); + + await modal.openForCodeEntry(); + await modal.updateComplete; + + typeCode("ABCDEFGH"); + codeSubmitButton()?.click(); + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + + confirmButton()?.click(); + + // Back on the code-entry form — not stuck on a dead-end confirm + // screen — with the same draft still there to correct. + await vi.waitFor(async () => { + await modal.updateComplete; + expect(codeInput()).not.toBeNull(); + }); + expect(confirmButton()).toBeNull(); + expect(codeInput()?.value).toBe("ABCDEFGH"); + expect(modal.textContent).toContain("steam_link_modal.reason_failed"); + + // Correct one character and resubmit — proceeds as a fresh attempt, + // not stuck showing the previous refusal. + redeemSteamLinkCodeMock.mockResolvedValueOnce({ ok: true }); + typeCode("ABCDEFGJ"); + codeSubmitButton()?.click(); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(confirmButton()?.disabled).toBe(false); + }); + // The stale failure from the first attempt must not resurface on this + // fresh ready state before Confirm has even been clicked again. + expect(modal.textContent).not.toContain("steam_link_modal.reason_failed"); + + confirmButton()?.click(); + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.textContent).toContain("steam_link_modal.success"); + }); + expect(redeemSteamLinkCodeMock).toHaveBeenLastCalledWith("ABCDEFGJ"); + }); + + 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")); + 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"); + }); + }); +});