mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-11 08:48:26 +00:00
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:
+106
-4
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user