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`
+
+ `;
+ }
+
+ 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`
+