From 274b516ea3891168348e5f1b79e0ec89f0385049 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 9 Jul 2026 08:02:45 -0700 Subject: [PATCH] feat(crazygames): backend login + surface the signed-in user (#4542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side CrazyGames login: exchange the SDK user token for our session, then surface that identity in the UI. Implements the client side of the [CrazyGames login handoff guide](https://docs.crazygames.com/sdk/user/). ## Part 1 — Backend login (token exchange) On CrazyGames we exchange the SDK's user token for our own session via `POST /auth/crazygames`, instead of the cookie-based `/auth/refresh` (the refresh cookie is `SameSite=Lax` and unusable from the CrazyGames iframe). - **`CrazyGamesSDK.ts`** — `getUserToken()` wrapper (awaits `ready()`, gates on `isUserAccountAvailable`, returns `null` on throw / no signed-in user). - **`Auth.ts`** — `doRefreshJwt()` routes to `doCrazyGamesLogin()` when on CrazyGames with a signed-in account; a `null` token (guest / no account) falls through to the existing `/auth/refresh` flow. `reauthAfterCrazyGamesChange()` drops the cached session on a mid-session sign-in. - **`Main.ts`** — `addAuthListener` re-runs auth + `getUserMe()` when the player signs into CrazyGames mid-session. Everything funnels through the existing `userAuth()` → `refreshJwt()` path, so **startup login and the 15-min re-exchange on expiry come for free** — no new expiry/polling code. ## Part 2 — Surface the signed-in user - **Account button** shows the CrazyGames avatar + username when signed in (clicking opens the account modal), or a **"Sign in"** that opens CrazyGames' own `showAuthPrompt()` when a guest. Wired across every entry point: the desktop nav pill, the mobile hamburger item (un-hidden on CrazyGames by dropping `.no-crazygames`), and — since CrazyGames renders below the `lg` breakpoint where the desktop nav is hidden — a new button in the **homepage top bar's** right slot. - **AccountModal** treats the CrazyGames user as logged-in: "Connected as" avatar + username, currency/subscription, and stats/games/friends. **No Discord/Google/email login+link buttons and no logout** (CrazyGames owns the account). A guest who reaches the modal gets a CrazyGames sign-in button, never Discord/Google. - **`CrazyGamesSDK.ts`** — `getUserProfile()` + `showAuthPrompt()` wrappers; `profilePictureUrl` added to the user type. Identity comes from the SDK (`getUser()`), not `/users/@me`, which doesn't surface CrazyGames identity yet. ## Behavior - **Signed-in CrazyGames account** → real backend session; avatar + username in the top bar; CrazyGames-only account modal. - **CrazyGames guest / not signed in** → silent fallback to guest; "Sign in" button opens `showAuthPrompt()`; auto-logged-in the moment they sign in (via `addAuthListener`). - **Not on CrazyGames** → completely unchanged. ## Testing - `npx tsc --noEmit` — clean - `npx eslint` on changed files — clean - No new i18n keys (reuses `main.sign_in`, `account_modal.*`). - No unit tests: this repo tests core sim only, and CrazyGames only initializes inside a crazygames.com iframe, so the CG paths can't run locally (the localhost SDK mock returns unsigned tokens the backend rejects). **Needs a manual pass on the CrazyGames game page (gameId `64178`)** covering: signed-in avatar/username + account modal, guest "Sign in" → prompt, and mid-session sign-in. ## Assumptions to confirm (backend) 1. The `/auth/crazygames` JWT carries the same `iss` (`getApiBase()`) and `aud` (`getAudience()`) as normal openfront.io tokens and satisfies `TokenPayloadSchema` (incl. base64url `sub`) — otherwise `userAuth()` will `logOut()`. 2. CrazyGames iframes our own origin (so `getApiBase()` → `https://api.openfront.io`), consistent with the existing integration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/client/AccountModal.ts | 104 ++++++++++++++++++++++++- src/client/Auth.ts | 60 ++++++++++++++ src/client/CrazyGamesAccountButton.ts | 102 ++++++++++++++++++++++++ src/client/CrazyGamesSDK.ts | 94 +++++++++++++++++----- src/client/GameModeSelector.ts | 29 ++++--- src/client/Main.ts | 22 +++++- src/client/Matchmaking.ts | 22 ++++-- src/client/Navigation.ts | 28 +++---- src/client/components/DesktopNavBar.ts | 4 +- src/client/components/MobileNavBar.ts | 3 +- src/client/components/PlayPage.ts | 43 +++++++++- src/client/components/RankedModal.ts | 16 +++- 12 files changed, 457 insertions(+), 70 deletions(-) create mode 100644 src/client/CrazyGamesAccountButton.ts diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index f7ca307b4..b204ed58e 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -4,12 +4,13 @@ import { ClientEnv } from "src/client/ClientEnv"; import { PlayerStatsTree, UserMeResponse } from "../core/ApiSchemas"; import { assetUrl } from "../core/AssetUrls"; import { Cosmetics } from "../core/CosmeticSchemas"; -import { fetchPlayerById, getUserMe } from "./Api"; +import { fetchPlayerById, getUserMe, invalidateUserMe } from "./Api"; import { discordLogin, googleLogin, linkGoogle, logOut, + reauthAfterCrazyGamesChange, sendMagicLink, } from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; @@ -25,6 +26,7 @@ import "./components/FriendsList"; import "./components/SubscriptionPanel"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; +import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK"; import { translateText } from "./Utils"; @customElement("account-modal") @@ -33,6 +35,9 @@ export class AccountModal extends BaseModal { @state() private email: string = ""; @state() private isLoadingUser: boolean = false; + // Set on CrazyGames when a CrazyGames user is signed in. Their identity comes + // from the SDK, not our backend user object. + @state() private crazyGamesUser: CrazyGamesUser | null = null; private userMeResponse: UserMeResponse | null = null; private statsTree: PlayerStatsTree | null = null; @@ -44,6 +49,9 @@ export class AccountModal extends BaseModal { super(); document.addEventListener("userMeResponse", (event: Event) => { + // A CrazyGames sign-in fires userMeResponse (via Main's auth listener); + // re-fetch the SDK profile so the modal leaves the sign-in screen. + this.refreshCrazyGamesUser(); const customEvent = event as CustomEvent; if (customEvent.detail) { const previousPublicId = this.userMeResponse?.player?.publicId; @@ -64,6 +72,16 @@ export class AccountModal extends BaseModal { }); } + // Refresh the signed-in CrazyGames identity from the SDK. No-op off + // CrazyGames; drives isLinkedAccount() so the modal shows the profile. + private refreshCrazyGamesUser() { + if (!crazyGamesSDK.isOnCrazyGames()) return; + void crazyGamesSDK.getUserProfile().then((user) => { + this.crazyGamesUser = user; + this.requestUpdate(); + }); + } + private hasAnyStats(): boolean { if (!this.statsTree) return false; // Check if statsTree has any data @@ -105,7 +123,13 @@ export class AccountModal extends BaseModal { private isLinkedAccount(): boolean { const me = this.userMeResponse?.user; - return !!(me?.discord ?? me?.google ?? me?.email); + // The CrazyGames identity only counts once the backend token exchange + // produced a session — otherwise a failed exchange would show a dead + // "connected as" view with no way to retry. + return ( + !!(me?.discord ?? me?.google ?? me?.email) || + (!!this.crazyGamesUser && this.userMeResponse !== null) + ); } protected modalConfig() { @@ -130,7 +154,9 @@ export class AccountModal extends BaseModal { } if (!this.isLinkedAccount()) { return html`
- ${this.renderLoginOptions()} + ${crazyGamesSDK.isOnCrazyGames() + ? this.renderCrazyGamesSignIn() + : this.renderLoginOptions()}
`; } return html` @@ -159,6 +185,9 @@ export class AccountModal extends BaseModal { } private renderAccountTab(): TemplateResult { + if (this.crazyGamesUser) { + return this.renderCrazyGamesAccount(this.crazyGamesUser); + } return html`
@@ -181,6 +210,59 @@ export class AccountModal extends BaseModal { `; } + // 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). + private renderCrazyGamesAccount(user: CrazyGamesUser): TemplateResult { + return html` +
+
+
+
+ ${translateText("account_modal.connected_as")} +
+
+ ${user.username} +
${user.username}
+ ${this.renderCurrency()} +
+
+
+ ${this.renderSubscriptionPanel()} +
+ `; + } + + // Shown when a CrazyGames guest opens the modal: hand off to CrazyGames' own + // sign-in prompt (no Discord/Google/email on CrazyGames). + private renderCrazyGamesSignIn(): TemplateResult { + return html` +
+
+

+ ${translateText("account_modal.sign_in_desc")} +

+ +
+
+ `; + } + private renderStatsTab(): TemplateResult { if (!this.hasAnyStats()) { return this.renderEmptyState( @@ -497,6 +579,20 @@ export class AccountModal extends BaseModal { } } + // CrazyGames sign-in: after their prompt completes, exchange the new token + // for a session and refresh the modal so it shows the signed-in profile. + private async handleCrazyGamesSignIn() { + await crazyGamesSDK.showAuthPrompt(); + const profile = await crazyGamesSDK.getUserProfile(); + if (!profile) return; // prompt cancelled / still not signed in + invalidateUserMe(); + await reauthAfterCrazyGamesChange(); + const userMe = await getUserMe(); + if (userMe) this.userMeResponse = userMe; + this.crazyGamesUser = profile; + this.requestUpdate(); + } + private handleDiscordLogin() { discordLogin(); } @@ -556,6 +652,8 @@ export class AccountModal extends BaseModal { this.isLoadingUser = true; this.handleLinkResult(args); + this.refreshCrazyGamesUser(); + void fetchCosmetics().then((cosmetics) => { this.cosmetics = cosmetics; this.requestUpdate(); diff --git a/src/client/Auth.ts b/src/client/Auth.ts index e2b51e382..52fdd1662 100644 --- a/src/client/Auth.ts +++ b/src/client/Auth.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; import { base64urlToUuid } from "../core/Base64"; import { getApiBase, getAudience } from "./Api"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { generateCryptoRandomUUID } from "./Utils"; export type UserAuth = { jwt: string; claims: TokenPayload } | false; @@ -189,6 +190,15 @@ async function refreshJwt(): Promise { } async function doRefreshJwt(): Promise { + if (crazyGamesSDK.isOnCrazyGames()) { + const token = await crazyGamesSDK.getUserToken(); + if (token) { + // Signed-in CrazyGames account: exchange their token for our session. + // No CrazyGames account / not signed in falls through to the guest flow + // below. + return doCrazyGamesLogin(token); + } + } try { console.log("Refreshing jwt"); const response = await fetch(getApiBase() + "/auth/refresh", { @@ -213,6 +223,56 @@ async function doRefreshJwt(): Promise { } } +// Exchange a CrazyGames user token for our session. On CrazyGames the refresh +// cookie isn't usable (SameSite=Lax, cross-site iframe), so we re-exchange on +// expiry instead of hitting /auth/refresh. +async function doCrazyGamesLogin(token: string): Promise { + try { + console.log("Logging in with CrazyGames"); + const response = await fetch(getApiBase() + "/auth/crazygames", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (response.status !== 200) { + console.error("CrazyGames login failed", response); + __jwt = null; + return; + } + const json = await response.json(); + const { jwt, expiresIn } = json; + __expiresAt = Date.now() + expiresIn * 1000; + console.log("CrazyGames login succeeded"); + __jwt = jwt; + } catch (e) { + console.error("CrazyGames login failed", e); + __jwt = null; + } +} + +// Called when the CrazyGames auth state changes mid-session (e.g. the player +// signs in): drop the cached session so userAuth() re-exchanges the new token. +// Single-flight: Main's auth listener and the account modal's sign-in handler +// can both react to the same sign-in; sharing one exchange keeps them from +// racing on __jwt. Any refresh already in flight is allowed to settle first so +// its stale result can't satisfy the reauth. +let __reauthPromise: Promise | null = null; +export async function reauthAfterCrazyGamesChange(): Promise { + __reauthPromise ??= (async () => { + try { + if (__refreshPromise) { + await __refreshPromise.catch(() => {}); + } + __jwt = null; + __expiresAt = 0; + return await userAuth(); + } finally { + __reauthPromise = null; + } + })(); + return __reauthPromise; +} + export async function sendMagicLink(email: string): Promise { try { const apiBase = getApiBase(); diff --git a/src/client/CrazyGamesAccountButton.ts b/src/client/CrazyGamesAccountButton.ts new file mode 100644 index 000000000..60a9c68ce --- /dev/null +++ b/src/client/CrazyGamesAccountButton.ts @@ -0,0 +1,102 @@ +import { crazyGamesSDK } from "./CrazyGamesSDK"; +import { closeMobileSidebar } from "./Navigation"; +import { translateText } from "./Utils"; + +// On CrazyGames the player's identity comes from the CrazyGames SDK, not our +// backend user object. Show their avatar + username when signed in (clicking +// opens the account modal), or a "Sign in" affordance that opens CrazyGames' +// own auth prompt when they're a guest. Applies to every account entry point: +// the desktop nav pill, the mobile hamburger item, and the homepage top bar +// (which layout is visible depends on viewport width). +export async function updateCrazyGamesNavButton() { + if (!crazyGamesSDK.isOnCrazyGames()) return; + const profile = await crazyGamesSDK.getUserProfile(); + const signInText = translateText("main.sign_in"); + + // Bypass the data-page router (which would open the account modal) and hand + // off to CrazyGames' own sign-in prompt instead. stopPropagation also skips + // the router's sidebar cleanup, so close it ourselves. + const promptSignIn = (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + closeMobileSidebar(); + void crazyGamesSDK.showAuthPrompt(); + }; + + // Desktop nav pill: avatar + person icon + text. + const desktopButton = document.getElementById( + "nav-account-button", + ) as HTMLButtonElement | null; + const avatarEl = document.getElementById( + "nav-account-avatar", + ) as HTMLImageElement | null; + const personIconEl = document.getElementById("nav-account-person-icon"); + // CrazyGames accounts have no email, so the email badge is always hidden. + document.getElementById("nav-account-email-badge")?.classList.add("hidden"); + const signInTextEl = document.getElementById("nav-account-signin-text"); + if (profile) { + if (avatarEl) { + avatarEl.alt = profile.username; + avatarEl.src = profile.profilePictureUrl; + avatarEl.classList.remove("hidden"); + } + personIconEl?.classList.add("hidden"); + if (signInTextEl) { + // The translation pass rewrites every [data-i18n] element's text, which + // would clobber the username — drop the attribute while it holds one. + signInTextEl.removeAttribute("data-i18n"); + signInTextEl.textContent = profile.username; + signInTextEl.classList.remove("hidden"); + } + desktopButton?.classList.remove("border", "border-white/20"); + if (desktopButton) desktopButton.onclick = null; + } else { + avatarEl?.classList.add("hidden"); + personIconEl?.classList.remove("hidden"); + if (signInTextEl) { + // Restore so language changes keep the label translated. + signInTextEl.setAttribute("data-i18n", "main.sign_in"); + signInTextEl.textContent = signInText; + signInTextEl.classList.remove("hidden"); + } + desktopButton?.classList.add("border", "border-white/20"); + if (desktopButton) desktopButton.onclick = promptSignIn; + } + + // Mobile hamburger menu item: text only. Same data-i18n handling as above. + const mobileButton = document.getElementById( + "mobile-nav-account-button", + ) as HTMLButtonElement | null; + if (mobileButton) { + if (profile) { + mobileButton.removeAttribute("data-i18n"); + mobileButton.textContent = profile.username; + } else { + mobileButton.setAttribute("data-i18n", "main.sign_in"); + mobileButton.textContent = signInText; + } + mobileButton.onclick = profile ? null : promptSignIn; + } + + // Homepage top bar (narrow layout): avatar or person icon only. + const topBarButton = document.getElementById( + "crazygames-account-btn", + ) as HTMLButtonElement | null; + const topBarAvatar = document.getElementById( + "crazygames-account-avatar", + ) as HTMLImageElement | null; + const topBarIcon = document.getElementById("crazygames-account-icon"); + if (profile) { + if (topBarAvatar) { + topBarAvatar.alt = profile.username; + topBarAvatar.src = profile.profilePictureUrl; + topBarAvatar.classList.remove("hidden"); + } + topBarIcon?.classList.add("hidden"); + if (topBarButton) topBarButton.onclick = null; + } else { + topBarAvatar?.classList.add("hidden"); + topBarIcon?.classList.remove("hidden"); + if (topBarButton) topBarButton.onclick = promptSignIn; + } +} diff --git a/src/client/CrazyGamesSDK.ts b/src/client/CrazyGamesSDK.ts index ae2d5bf1d..5c938fcdd 100644 --- a/src/client/CrazyGamesSDK.ts +++ b/src/client/CrazyGamesSDK.ts @@ -1,18 +1,20 @@ +export interface CrazyGamesUser { + username: string; + profilePictureUrl: string; +} + declare global { interface Window { CrazyGames?: { SDK: { init: () => Promise; user: { - getUser(): Promise<{ - username: string; - } | null>; + isUserAccountAvailable: boolean; + getUser(): Promise; + getUserToken(): Promise; + showAuthPrompt(): Promise; addAuthListener: ( - listener: ( - user: { - username: string; - } | null, - ) => void, + listener: (user: CrazyGamesUser | null) => void, ) => void; }; ad: { @@ -58,8 +60,10 @@ declare global { export class CrazyGamesSDK { private initialized = false; private isGameplayActive = false; - private readyPromise: Promise; - private resolveReady!: () => void; + // Resolves true once the SDK initialized, false once init definitively + // failed (not on CrazyGames, SDK never loaded, init threw). + private readyPromise: Promise; + private resolveReady!: (ready: boolean) => void; constructor() { this.readyPromise = new Promise((resolve) => { @@ -72,9 +76,17 @@ export class CrazyGamesSDK { setTimeout(() => resolve(false), 3000); }); - const ready = this.readyPromise.then(() => true); + return Promise.race([this.readyPromise, timeout]); + } - return Promise.race([ready, timeout]); + // Like ready() but without the 3s cap: waits for maybeInit() to actually + // finish (SDK load alone can take ~10s on a slow network). Use this for + // auth-critical calls, where a premature false logs the player out. + private whenReady(): Promise { + if (!this.isOnCrazyGames()) { + return Promise.resolve(false); + } + return this.readyPromise; } isOnCrazyGames(): boolean { @@ -110,6 +122,7 @@ export class CrazyGamesSDK { if (!this.isOnCrazyGames()) { console.log("Not running on CrazyGames platform, not initializing SDK"); + this.resolveReady(false); return; } @@ -122,16 +135,18 @@ export class CrazyGamesSDK { if (typeof window.CrazyGames === "undefined") { console.warn("CrazyGames SDK not available"); + this.resolveReady(false); return; } try { await window.CrazyGames.SDK.init(); this.initialized = true; - this.resolveReady(); + this.resolveReady(true); console.log("CrazyGames SDK initialized"); } catch (error) { console.error("Failed to initialize CrazyGames SDK:", error); + this.resolveReady(false); } } @@ -148,14 +163,55 @@ export class CrazyGamesSDK { } } + // Returns a fresh CrazyGames-signed user token to exchange with our backend, + // or null if accounts aren't available here or no user is signed in. + // CrazyGames recommends fetching this fresh each time rather than caching it. + async getUserToken(): Promise { + if (!(await this.whenReady())) { + return null; + } + try { + if (!window.CrazyGames!.SDK.user.isUserAccountAvailable) { + return null; + } + return await window.CrazyGames!.SDK.user.getUserToken(); + } catch (e) { + console.log("error getting CrazyGames user token: ", e); + return null; + } + } + + // Returns the signed-in CrazyGames user (username + avatar), or null if + // accounts aren't available here or no user is signed in. + async getUserProfile(): Promise { + if (!(await this.whenReady())) { + return null; + } + try { + return await window.CrazyGames!.SDK.user.getUser(); + } catch (e) { + console.log("error getting CrazyGames user: ", e); + return null; + } + } + + // Opens CrazyGames' own sign-in prompt. On success the auth listener fires, + // which drives our re-auth. Resolves regardless of outcome (e.g. cancelled). + async showAuthPrompt(): Promise { + if (!(await this.whenReady())) { + return; + } + try { + await window.CrazyGames!.SDK.user.showAuthPrompt(); + } catch (e) { + console.log("CrazyGames auth prompt dismissed: ", e); + } + } + async addAuthListener( - listener: ( - user: { - username: string; - } | null, - ) => void, + listener: (user: CrazyGamesUser | null) => void, ): Promise { - if (!(await this.ready())) { + if (!(await this.whenReady())) { return; } diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 800f6b631..56b2b2528 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -11,7 +11,6 @@ import { } from "../core/game/Game"; import { PublicGameInfo, PublicGames } from "../core/Schemas"; import "./components/IOSAddToHomeScreenBanner"; -import { crazyGamesSDK } from "./CrazyGamesSDK"; import { HostLobbyModal } from "./HostLobbyModal"; import { JoinLobbyModal } from "./JoinLobbyModal"; import { PublicLobbySocket } from "./LobbySocket"; @@ -145,13 +144,11 @@ export class GameModeSelector extends LitElement { this.openHostLobby, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", )} - ${!crazyGamesSDK.isOnCrazyGames() - ? this.renderSmallActionCard( - translateText("mode_selector.ranked_title"), - this.openRankedMenu, - "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", - ) - : html``} + ${this.renderSmallActionCard( + translateText("mode_selector.ranked_title"), + this.openRankedMenu, + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", + )} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, @@ -160,7 +157,9 @@ export class GameModeSelector extends LitElement { )}
- + ${this.lobbies === null @@ -226,13 +225,11 @@ export class GameModeSelector extends LitElement { this.openHostLobby, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", )} - ${!crazyGamesSDK.isOnCrazyGames() - ? this.renderSmallActionCard( - translateText("mode_selector.ranked_title"), - this.openRankedMenu, - "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", - ) - : html``} + ${this.renderSmallActionCard( + translateText("mode_selector.ranked_title"), + this.openRankedMenu, + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", + )} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, diff --git a/src/client/Main.ts b/src/client/Main.ts index 4ff95ed64..3fba486d4 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -15,10 +15,11 @@ import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; import { getUserMe, invalidateUserMe } from "./Api"; -import { userAuth } from "./Auth"; +import { reauthAfterCrazyGamesChange, userAuth } from "./Auth"; import "./ClanModal"; import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner"; import { getPlayerCosmeticsRefs } from "./Cosmetics"; +import { updateCrazyGamesNavButton } from "./CrazyGamesAccountButton"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import "./EffectsInput"; import "./EffectsModal"; @@ -504,7 +505,11 @@ class Client { } const onUserMe = async (userMeResponse: UserMeResponse | false) => { - updateAccountNavButton(userMeResponse); + if (crazyGamesSDK.isOnCrazyGames()) { + void updateCrazyGamesNavButton(); + } else { + updateAccountNavButton(userMeResponse); + } const isAdFree = userMeResponse !== false && userMeResponse.player?.adfree === true; window.adsEnabled = !isAdFree && !crazyGamesSDK.isOnCrazyGames(); @@ -534,6 +539,15 @@ class Client { getUserMe().then(onUserMe); } + // Re-run auth when the player signs into CrazyGames mid-session. Logout + // reloads the page, so only login needs handling here. + crazyGamesSDK.addAuthListener(() => { + invalidateUserMe(); + reauthAfterCrazyGamesChange().then((result) => + result === false ? onUserMe(false) : getUserMe().then(onUserMe), + ); + }); + const settingsModal = document.querySelector( "user-setting", ) as UserSettingModal; @@ -1107,6 +1121,10 @@ const bootstrap = () => { // Also hide elements after a short delay to catch late-rendered components setTimeout(hideCrazyGamesElements, 100); setTimeout(hideCrazyGamesElements, 500); + + // Populate the CrazyGames account buttons once the nav/top-bar have rendered + // (onUserMe also refreshes them after auth and on mid-session sign-in). + setTimeout(() => void updateCrazyGamesNavButton(), 500); }; if (document.readyState === "loading") { diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index ed848430e..c65f092fd 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -7,6 +7,7 @@ import { getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import { modalHeader } from "./components/ui/ModalHeader"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { translateText } from "./Utils"; @@ -119,13 +120,20 @@ export class MatchmakingModal extends BaseModal { return; } - const isLoggedIn = - userMe && - userMe.user && - (userMe.user.discord !== undefined || - userMe.user.google !== undefined || - userMe.user.email !== undefined); - if (!isLoggedIn) { + // CrazyGames players authenticate through the SDK rather than a linked + // Discord/Google/email account, so a signed-in CrazyGames user counts as + // logged in for ranked. + const crazyGamesSignedIn = + crazyGamesSDK.isOnCrazyGames() && + (await crazyGamesSDK.getUserProfile()) !== null; + if (!this.isModalOpen) { + return; + } + + if ( + userMe === false || + (!hasLinkedAccount(userMe) && !crazyGamesSignedIn) + ) { window.dispatchEvent( new CustomEvent("show-message", { detail: { diff --git a/src/client/Navigation.ts b/src/client/Navigation.ts index f52aec437..f83830a9c 100644 --- a/src/client/Navigation.ts +++ b/src/client/Navigation.ts @@ -1,18 +1,18 @@ -export function initNavigation() { - const closeMobileSidebar = () => { - const sidebar = document.getElementById("sidebar-menu"); - const backdrop = document.getElementById("mobile-menu-backdrop"); - if (sidebar?.classList.contains("open")) { - sidebar.classList.remove("open"); - backdrop?.classList.remove("open"); - document.documentElement.classList.remove("overflow-hidden"); - sidebar.setAttribute("aria-hidden", "true"); - backdrop?.setAttribute("aria-hidden", "true"); - const hb = document.getElementById("hamburger-btn"); - if (hb) hb.setAttribute("aria-expanded", "false"); - } - }; +export function closeMobileSidebar() { + const sidebar = document.getElementById("sidebar-menu"); + const backdrop = document.getElementById("mobile-menu-backdrop"); + if (sidebar?.classList.contains("open")) { + sidebar.classList.remove("open"); + backdrop?.classList.remove("open"); + document.documentElement.classList.remove("overflow-hidden"); + sidebar.setAttribute("aria-hidden", "true"); + backdrop?.setAttribute("aria-hidden", "true"); + const hb = document.getElementById("hamburger-btn"); + if (hb) hb.setAttribute("aria-expanded", "false"); + } +} +export function initNavigation() { const showPage = (pageId: string) => { window.currentPageId = pageId; diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 5786c8057..69dc271c1 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -148,14 +148,14 @@ export class DesktopNavBar extends LitElement {
diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index 13be0de92..7da39f2e9 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { assetUrl } from "../../core/AssetUrls"; +import { crazyGamesSDK } from "../CrazyGamesSDK"; import "./NewsBox"; @customElement("play-page") @@ -59,10 +60,44 @@ export class PlayPage extends LitElement { /> - + ${crazyGamesSDK.isOnCrazyGames() + ? html` + + ` + : html` + + `} diff --git a/src/client/components/RankedModal.ts b/src/client/components/RankedModal.ts index bfb831890..94cf987b1 100644 --- a/src/client/components/RankedModal.ts +++ b/src/client/components/RankedModal.ts @@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators.js"; import { UserMeResponse } from "../../core/ApiSchemas"; import { getUserMe, hasLinkedAccount } from "../Api"; import { userAuth } from "../Auth"; +import { crazyGamesSDK } from "../CrazyGamesSDK"; import { translateText } from "../Utils"; import { BaseModal } from "./BaseModal"; import { modalHeader } from "./ui/ModalHeader"; @@ -14,6 +15,14 @@ export class RankedModal extends BaseModal { @state() private elo: number | string = "..."; @state() private userMeResponse: UserMeResponse | false = false; @state() private errorMessage: string | null = null; + // CrazyGames players authenticate through the SDK, not a linked + // Discord/Google/email account, so track that separately for ranked. + @state() private crazyGamesSignedIn = false; + + // Eligible to see/play ranked: a linked account or a signed-in CrazyGames one. + private isRankedEligible(): boolean { + return hasLinkedAccount(this.userMeResponse) || this.crazyGamesSignedIn; + } constructor() { super(); @@ -50,7 +59,7 @@ export class RankedModal extends BaseModal { return; } - if (hasLinkedAccount(this.userMeResponse)) { + if (this.isRankedEligible()) { this.elo = this.userMeResponse && this.userMeResponse.player.leaderboard?.oneVone?.elo @@ -66,6 +75,9 @@ export class RankedModal extends BaseModal { try { const userMe = await getUserMe(); this.userMeResponse = userMe; + this.crazyGamesSignedIn = + crazyGamesSDK.isOnCrazyGames() && + (await crazyGamesSDK.getUserProfile()) !== null; } catch (error) { console.error("Failed to fetch user profile for ranked modal", error); this.userMeResponse = false; @@ -95,7 +107,7 @@ export class RankedModal extends BaseModal { ${this.renderCard( translateText("mode_selector.ranked_1v1_title"), this.errorMessage ?? - (hasLinkedAccount(this.userMeResponse) + (this.isRankedEligible() ? translateText("matchmaking_modal.elo", { elo: this.elo }) : translateText("mode_selector.ranked_title")), () => this.handleRanked(),