feat(crazygames): backend login + surface the signed-in user (#4542)

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/).

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.

- **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.

- **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.

- `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.

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) <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-09 08:02:45 -07:00
committed by evanpelle
parent 9029ee40fd
commit 0a44f4d3db
12 changed files with 462 additions and 69 deletions
+106 -4
View File
@@ -4,8 +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 { discordLogin, logOut, sendMagicLink } from "./Auth";
import { fetchPlayerById, getUserMe, invalidateUserMe } from "./Api";
import {
discordLogin,
logOut,
reauthAfterCrazyGamesChange,
sendMagicLink,
} from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/PlayerGameHistoryView";
import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView";
@@ -19,6 +24,7 @@ import "./components/FriendsList";
import "./components/SubscriptionPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics, SUBSCRIPTIONS_ENABLED } from "./Cosmetics";
import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK";
import { translateText } from "./Utils";
@customElement("account-modal")
@@ -27,6 +33,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 cosmetics: Cosmetics | null = null;
@@ -38,6 +47,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;
@@ -58,6 +70,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
@@ -99,7 +121,13 @@ export class AccountModal extends BaseModal {
private isLinkedAccount(): boolean {
const me = this.userMeResponse?.user;
return !!(me?.discord ?? 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?.email) ||
(!!this.crazyGamesUser && this.userMeResponse !== null)
);
}
protected modalConfig() {
@@ -124,7 +152,9 @@ export class AccountModal extends BaseModal {
}
if (!this.isLinkedAccount()) {
return html`<div class="custom-scrollbar mr-1">
${this.renderLoginOptions()}
${crazyGamesSDK.isOnCrazyGames()
? this.renderCrazyGamesSignIn()
: this.renderLoginOptions()}
</div>`;
}
return html`
@@ -153,6 +183,9 @@ export class AccountModal extends BaseModal {
}
private renderAccountTab(): TemplateResult {
if (this.crazyGamesUser) {
return this.renderCrazyGamesAccount(this.crazyGamesUser);
}
return html`
<div class="flex flex-col gap-6">
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
@@ -175,6 +208,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`
<div class="flex flex-col gap-6">
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<div class="flex flex-col items-center gap-4">
<div
class="text-xs text-white/40 uppercase tracking-widest font-bold border-b border-white/5 pb-2 px-8"
>
${translateText("account_modal.connected_as")}
</div>
<div class="flex flex-col items-center gap-3">
<img
src=${user.profilePictureUrl}
alt=${user.username}
class="w-16 h-16 rounded-full object-cover"
referrerpolicy="no-referrer"
/>
<div class="text-white text-lg font-medium">${user.username}</div>
${this.renderCurrency()}
</div>
</div>
</div>
${this.renderSubscriptionPanel()}
</div>
`;
}
// 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`
<div class="flex items-center justify-center p-6 min-h-full">
<div
class="w-full max-w-md bg-white/5 rounded-2xl border border-white/10 p-8 text-center"
>
<p class="text-white/50 text-sm font-medium mb-6">
${translateText("account_modal.sign_in_desc")}
</p>
<o-button
variant="primary"
width="block"
size="md"
translationKey="main.sign_in"
@click=${this.handleCrazyGamesSignIn}
></o-button>
</div>
</div>
`;
}
private renderStatsTab(): TemplateResult {
if (!this.hasAnyStats()) {
return this.renderEmptyState(
@@ -418,6 +504,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();
}
@@ -425,6 +525,8 @@ export class AccountModal extends BaseModal {
protected onOpen(): void {
this.isLoadingUser = true;
this.refreshCrazyGamesUser();
if (SUBSCRIPTIONS_ENABLED) {
void fetchCosmetics().then((cosmetics) => {
this.cosmetics = cosmetics;
+60
View File
@@ -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;
@@ -154,6 +155,15 @@ async function refreshJwt(): Promise<void> {
}
async function doRefreshJwt(): Promise<void> {
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", {
@@ -178,6 +188,56 @@ async function doRefreshJwt(): Promise<void> {
}
}
// 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<void> {
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<UserAuth> | null = null;
export async function reauthAfterCrazyGamesChange(): Promise<UserAuth> {
__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<boolean> {
try {
const apiBase = getApiBase();
+102
View File
@@ -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;
}
}
+75 -19
View File
@@ -1,18 +1,20 @@
export interface CrazyGamesUser {
username: string;
profilePictureUrl: string;
}
declare global {
interface Window {
CrazyGames?: {
SDK: {
init: () => Promise<void>;
user: {
getUser(): Promise<{
username: string;
} | null>;
isUserAccountAvailable: boolean;
getUser(): Promise<CrazyGamesUser | null>;
getUserToken(): Promise<string>;
showAuthPrompt(): Promise<CrazyGamesUser | null>;
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<void>;
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<boolean>;
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<boolean> {
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<string | null> {
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<CrazyGamesUser | null> {
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<void> {
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<void> {
if (!(await this.ready())) {
if (!(await this.whenReady())) {
return;
}
+13 -16
View File
@@ -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`<div class="invisible"></div>`}
${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,
@@ -159,7 +156,9 @@ export class GameModeSelector extends LitElement {
)}
</div>
<!-- iOS Add to Home Screen banner -->
<ios-add-to-home-screen-banner></ios-add-to-home-screen-banner>
<ios-add-to-home-screen-banner
class="no-crazygames"
></ios-add-to-home-screen-banner>
<!-- Game cards grid -->
${this.lobbies === null
@@ -225,13 +224,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`<div class="invisible"></div>`}
${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,
+20 -2
View File
@@ -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 "./FlagInput";
import { FlagInput } from "./FlagInput";
@@ -477,7 +478,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();
@@ -507,6 +512,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;
@@ -1065,6 +1079,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") {
+15 -5
View File
@@ -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,11 +120,20 @@ export class MatchmakingModal extends BaseModal {
return;
}
const isLoggedIn =
userMe &&
userMe.user &&
(userMe.user.discord !== undefined || userMe.user.email !== undefined);
if (!isLoggedIn) {
// CrazyGames players authenticate through the SDK rather than a linked
// Discord/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: {
+14 -14
View File
@@ -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;
+2 -2
View File
@@ -148,14 +148,14 @@ export class DesktopNavBar extends LitElement {
</div>
<button
id="nav-account-button"
class="no-crazygames nav-menu-item relative h-10 rounded-full overflow-hidden flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white"
class="nav-menu-item relative h-10 rounded-full overflow-hidden flex items-center justify-center gap-2 px-3 bg-transparent border border-white/20 text-white/80 hover:text-white cursor-pointer transition-colors [&.active]:text-white"
data-page="page-account"
data-i18n-aria-label="main.account"
data-i18n-title="main.account"
>
<img
id="nav-account-avatar"
class="no-crazygames hidden w-8 h-8 rounded-full object-cover"
class="hidden w-8 h-8 rounded-full object-cover"
alt=""
data-i18n-alt="main.discord_avatar_alt"
referrerpolicy="no-referrer"
+2 -1
View File
@@ -138,7 +138,8 @@ export class MobileNavBar extends LitElement {
data-i18n="main.settings"
></button>
<button
class="no-crazygames nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
id="mobile-nav-account-button"
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-account"
data-i18n="main.account"
></button>
+39 -4
View File
@@ -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 {
/>
</div>
<div
aria-hidden="true"
class="col-start-3 justify-self-end h-10 shrink-0 aspect-[4/3]"
></div>
${crazyGamesSDK.isOnCrazyGames()
? html`
<button
id="crazygames-account-btn"
data-page="page-account"
class="nav-menu-item col-start-3 justify-self-end h-10 shrink-0 flex items-center justify-center rounded-full overflow-hidden text-white/90 cursor-pointer"
data-i18n-aria-label="main.account"
data-i18n-title="main.account"
>
<img
id="crazygames-account-avatar"
class="hidden w-8 h-8 rounded-full object-cover"
alt=""
referrerpolicy="no-referrer"
/>
<svg
id="crazygames-account-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="w-7 h-7"
>
<path d="M20 21a8 8 0 0 0-16 0" />
<path d="M12 13a4 4 0 1 0-4-4 4 4 0 0 0 4 4Z" />
</svg>
</button>
`
: html`
<div
aria-hidden="true"
class="col-start-3 justify-self-end h-10 shrink-0 aspect-[4/3]"
></div>
`}
</div>
</div>
+14 -2
View File
@@ -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(),