mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-13 02:46:04 +00:00
274b516ea3
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) <noreply@anthropic.com>
711 lines
24 KiB
TypeScript
711 lines
24 KiB
TypeScript
import { html, TemplateResult } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
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, invalidateUserMe } from "./Api";
|
|
import {
|
|
discordLogin,
|
|
googleLogin,
|
|
linkGoogle,
|
|
logOut,
|
|
reauthAfterCrazyGamesChange,
|
|
sendMagicLink,
|
|
} from "./Auth";
|
|
import "./components/baseComponents/stats/DiscordUserHeader";
|
|
import "./components/baseComponents/stats/PlayerGameHistoryView";
|
|
import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView";
|
|
import "./components/baseComponents/stats/PlayerStatsTable";
|
|
import "./components/baseComponents/stats/PlayerStatsTree";
|
|
import { BaseModal } from "./components/BaseModal";
|
|
import "./components/CopyButton";
|
|
import "./components/CurrencyDisplay";
|
|
import "./components/Difficulties";
|
|
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")
|
|
export class AccountModal extends BaseModal {
|
|
protected routerName = "account";
|
|
|
|
@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;
|
|
// Preserves the Games tab's accumulated list + cursor across tab switches.
|
|
private gameHistoryCache: PlayerGameHistoryCache | null = null;
|
|
private cosmetics: Cosmetics | null = null;
|
|
|
|
constructor() {
|
|
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;
|
|
this.userMeResponse = customEvent.detail as UserMeResponse;
|
|
// Reset whenever the player identity changes (login, or switching to a
|
|
// different account) so stats/history from the previous player don't
|
|
// linger.
|
|
if (this.userMeResponse?.player?.publicId !== previousPublicId) {
|
|
this.statsTree = null;
|
|
this.gameHistoryCache = null;
|
|
this.requestUpdate();
|
|
}
|
|
} else {
|
|
this.statsTree = null;
|
|
this.gameHistoryCache = null;
|
|
this.requestUpdate();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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
|
|
return (
|
|
Object.keys(this.statsTree).length > 0 &&
|
|
Object.values(this.statsTree).some(
|
|
(gameTypeStats) =>
|
|
gameTypeStats && Object.keys(gameTypeStats).length > 0,
|
|
)
|
|
);
|
|
}
|
|
|
|
protected renderHeaderSlot() {
|
|
const isLoggedIn = !!this.userMeResponse?.user;
|
|
const publicId = this.userMeResponse?.player?.publicId ?? "";
|
|
const displayId = publicId || translateText("account_modal.not_found");
|
|
return modalHeader({
|
|
title: translateText("account_modal.title"),
|
|
onBack: () => this.close(),
|
|
ariaLabel: translateText("common.back"),
|
|
rightContent:
|
|
isLoggedIn && !this.isLoadingUser
|
|
? html`
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
|
|
>${translateText("account_modal.public_player_id")}</span
|
|
>
|
|
<copy-button
|
|
.lobbyId=${publicId}
|
|
.copyText=${publicId}
|
|
.displayText=${displayId}
|
|
></copy-button>
|
|
</div>
|
|
`
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
private isLinkedAccount(): boolean {
|
|
const me = this.userMeResponse?.user;
|
|
// 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() {
|
|
if (this.isLoadingUser || !this.isLinkedAccount()) {
|
|
return {};
|
|
}
|
|
return {
|
|
tabs: [
|
|
{ key: "account", label: translateText("account_modal.tab_account") },
|
|
{ key: "stats", label: translateText("account_modal.tab_stats") },
|
|
{ key: "games", label: translateText("account_modal.tab_games") },
|
|
{ key: "friends", label: translateText("account_modal.tab_friends") },
|
|
],
|
|
};
|
|
}
|
|
|
|
protected renderBody(tab: string) {
|
|
if (this.isLoadingUser) {
|
|
return this.renderLoadingSpinner(
|
|
translateText("account_modal.fetching_account"),
|
|
);
|
|
}
|
|
if (!this.isLinkedAccount()) {
|
|
return html`<div class="custom-scrollbar mr-1">
|
|
${crazyGamesSDK.isOnCrazyGames()
|
|
? this.renderCrazyGamesSignIn()
|
|
: this.renderLoginOptions()}
|
|
</div>`;
|
|
}
|
|
return html`
|
|
<div class="custom-scrollbar mr-1">
|
|
<div class="p-6">${this.renderTab(tab)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderTab(tab: string): TemplateResult {
|
|
switch (tab) {
|
|
case "stats":
|
|
return this.renderStatsTab();
|
|
case "games":
|
|
return this.renderGamesTab();
|
|
case "friends":
|
|
return this.renderFriendsTab();
|
|
default:
|
|
return this.renderAccountTab();
|
|
}
|
|
}
|
|
|
|
private renderFriendsTab(): TemplateResult {
|
|
const myPublicId = this.userMeResponse?.player?.publicId ?? "";
|
|
return html`<friends-list .myPublicId=${myPublicId}></friends-list>`;
|
|
}
|
|
|
|
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">
|
|
<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 items-center gap-8 justify-center flex-wrap">
|
|
<discord-user-header
|
|
.data=${this.userMeResponse?.user?.discord ?? null}
|
|
></discord-user-header>
|
|
${this.renderLoggedInAs()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${this.renderSubscriptionPanel()}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 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(
|
|
"📊",
|
|
translateText("account_modal.no_stats"),
|
|
);
|
|
}
|
|
return html`
|
|
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
|
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
|
<span class="text-blue-400">📊</span>
|
|
${translateText("account_modal.stats_overview")}
|
|
</h3>
|
|
<player-stats-tree-view
|
|
.statsTree=${this.statsTree}
|
|
></player-stats-tree-view>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderGamesTab(): TemplateResult {
|
|
const publicId = this.userMeResponse?.player?.publicId ?? "";
|
|
if (!publicId) {
|
|
return this.renderEmptyState(
|
|
"🎮",
|
|
translateText("account_modal.no_games"),
|
|
);
|
|
}
|
|
return html`
|
|
<player-game-history-view
|
|
.publicId=${publicId}
|
|
.cachedState=${this.gameHistoryCache?.publicId === publicId
|
|
? this.gameHistoryCache
|
|
: null}
|
|
@history-updated=${(e: CustomEvent<PlayerGameHistoryCache>) => {
|
|
this.gameHistoryCache = e.detail;
|
|
}}
|
|
@view-game=${(e: CustomEvent<{ gameId: string }>) =>
|
|
void this.viewGame(e.detail.gameId)}
|
|
></player-game-history-view>
|
|
`;
|
|
}
|
|
|
|
private renderEmptyState(icon: string, message: string): TemplateResult {
|
|
return html`
|
|
<div
|
|
class="bg-white/5 rounded-xl border border-white/10 p-12 flex flex-col items-center justify-center text-center"
|
|
>
|
|
<div class="text-4xl mb-3">${icon}</div>
|
|
<p class="text-white/60 text-sm">${message}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderSubscriptionPanel(): TemplateResult | "" {
|
|
const sub = this.userMeResponse?.player?.subscription;
|
|
if (!sub) return "";
|
|
const cosmetic = this.cosmetics?.subscriptions?.[sub.tier] ?? null;
|
|
return html`<subscription-panel
|
|
.sub=${sub}
|
|
.cosmetic=${cosmetic}
|
|
></subscription-panel>`;
|
|
}
|
|
|
|
private renderCurrency(): TemplateResult {
|
|
const currency = this.userMeResponse?.player?.currency;
|
|
if (!currency) return html``;
|
|
|
|
return html`
|
|
<currency-display
|
|
.hard=${currency.hard}
|
|
.soft=${currency.soft}
|
|
></currency-display>
|
|
`;
|
|
}
|
|
|
|
private renderLoggedInAs(): TemplateResult {
|
|
const me = this.userMeResponse?.user;
|
|
if (me?.discord) {
|
|
return html`
|
|
<div class="flex flex-col items-center gap-3 w-full">
|
|
${this.renderCurrency()} ${this.renderGoogleLink()}
|
|
${this.renderLogoutButton()}
|
|
</div>
|
|
`;
|
|
} else if (me?.google) {
|
|
return html`
|
|
<div class="flex flex-col items-center gap-3 w-full">
|
|
<div class="text-white text-lg font-medium">
|
|
${translateText("account_modal.linked_account", {
|
|
account_name: me.google.email,
|
|
})}
|
|
</div>
|
|
${this.renderCurrency()} ${this.renderLogoutButton()}
|
|
</div>
|
|
`;
|
|
} else if (me?.email) {
|
|
return html`
|
|
<div class="flex flex-col items-center gap-3 w-full">
|
|
<div class="text-white text-lg font-medium">
|
|
${translateText("account_modal.linked_account", {
|
|
account_name: me.email,
|
|
})}
|
|
</div>
|
|
${this.renderCurrency()} ${this.renderGoogleLink()}
|
|
${this.renderLogoutButton()}
|
|
</div>
|
|
`;
|
|
}
|
|
return html``;
|
|
}
|
|
|
|
// Show the Google link state: a confirmation line when a Google account is
|
|
// already linked, otherwise the button to link one.
|
|
private renderGoogleLink(): TemplateResult {
|
|
const google = this.userMeResponse?.user?.google;
|
|
if (google) {
|
|
const label = google.email
|
|
? translateText("account_modal.linked_to_google_email", {
|
|
email: google.email,
|
|
})
|
|
: translateText("account_modal.linked_to_google");
|
|
return html`
|
|
<div class="flex items-center gap-2 text-white/70 text-sm">
|
|
<img
|
|
src=${assetUrl("images/GoogleLogo.svg")}
|
|
alt=${translateText("account_modal.google_alt")}
|
|
class="w-4 h-4"
|
|
/>
|
|
<span>${label}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
return this.renderLinkGoogleButton();
|
|
}
|
|
|
|
// Shown when logged in without a Google identity yet. Lets the user attach
|
|
// Google to their existing account (we never auto-merge by email).
|
|
private renderLinkGoogleButton(): TemplateResult {
|
|
if (this.userMeResponse?.user?.google) return html``;
|
|
return html`
|
|
<button
|
|
@click=${this.handleLinkGoogle}
|
|
class="w-full px-6 py-3 text-[#1f1f1f] bg-white hover:bg-[#f7f8f8] border border-[#dadce0] rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4285F4] transition-colors duration-200 flex items-center justify-center gap-3 shadow-lg"
|
|
>
|
|
<img
|
|
src=${assetUrl("images/GoogleLogo.svg")}
|
|
alt=${translateText("account_modal.google_alt")}
|
|
class="w-5 h-5"
|
|
/>
|
|
<span class="font-bold tracking-wide"
|
|
>${translateText("account_modal.link_google")}</span
|
|
>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
private async viewGame(gameId: string): Promise<void> {
|
|
this.close();
|
|
const encodedGameId = encodeURIComponent(gameId);
|
|
const newUrl = `/${ClientEnv.workerPath(gameId)}/game/${encodedGameId}`;
|
|
|
|
history.pushState({ join: gameId }, "", newUrl);
|
|
window.dispatchEvent(
|
|
new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }),
|
|
);
|
|
}
|
|
|
|
private renderLogoutButton(): TemplateResult {
|
|
return html`
|
|
<o-button
|
|
variant="danger"
|
|
size="md"
|
|
translationKey="account_modal.log_out"
|
|
@click=${this.handleLogout}
|
|
></o-button>
|
|
`;
|
|
}
|
|
|
|
private renderLoginOptions() {
|
|
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"
|
|
>
|
|
<div class="text-center mb-8">
|
|
<div
|
|
class="w-16 h-16 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-2xl flex items-center justify-center mx-auto mb-6 border border-white/10 shadow-inner"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="w-8 h-8 text-blue-400"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
|
<polyline points="10 17 15 12 10 7"></polyline>
|
|
<line x1="15" y1="12" x2="3" y2="12"></line>
|
|
</svg>
|
|
</div>
|
|
<p class="text-white/50 text-sm font-medium">
|
|
${translateText("account_modal.sign_in_desc")}
|
|
</p>
|
|
${this.renderCurrency()}
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Discord Login Button -->
|
|
<button
|
|
@click="${this.handleDiscordLogin}"
|
|
class="w-full px-6 py-4 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg hover:shadow-[#5865F2]/20"
|
|
>
|
|
<img
|
|
src=${assetUrl("images/DiscordLogo.svg")}
|
|
alt="Discord"
|
|
class="w-6 h-6 relative z-10"
|
|
/>
|
|
<span class="font-bold relative z-10 tracking-wide"
|
|
>${translateText("main.login_discord") ||
|
|
translateText("account_modal.link_discord")}</span
|
|
>
|
|
</button>
|
|
|
|
<!-- Google Login Button (Google brand guidelines: white surface,
|
|
dark text, the multicolor "G" mark) -->
|
|
<button
|
|
@click="${this.handleGoogleLogin}"
|
|
class="w-full px-6 py-4 text-[#1f1f1f] bg-white hover:bg-[#f7f8f8] border border-[#dadce0] rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4285F4] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg"
|
|
>
|
|
<img
|
|
src=${assetUrl("images/GoogleLogo.svg")}
|
|
alt=${translateText("account_modal.google_alt")}
|
|
class="w-6 h-6 relative z-10"
|
|
/>
|
|
<span class="font-bold relative z-10 tracking-wide"
|
|
>${translateText("main.login_google")}</span
|
|
>
|
|
</button>
|
|
|
|
<!-- Divider -->
|
|
<div class="flex items-center gap-4 py-2">
|
|
<div class="h-px bg-white/10 flex-1"></div>
|
|
<span
|
|
class="text-[10px] uppercase tracking-widest text-white/30 font-bold"
|
|
>
|
|
${translateText("account_modal.or")}
|
|
</span>
|
|
<div class="h-px bg-white/10 flex-1"></div>
|
|
</div>
|
|
|
|
<!-- Email Recovery -->
|
|
<div class="space-y-3">
|
|
<div class="relative group">
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
name="email"
|
|
.value="${this.email}"
|
|
@input="${this.handleEmailInput}"
|
|
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10"
|
|
placeholder="${translateText(
|
|
"account_modal.email_placeholder",
|
|
)}"
|
|
required
|
|
/>
|
|
</div>
|
|
<o-button
|
|
variant="primary"
|
|
width="block"
|
|
size="md"
|
|
translationKey="account_modal.get_magic_link"
|
|
@click=${this.handleSubmit}
|
|
></o-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-8 text-center border-t border-white/10 pt-6">
|
|
<button
|
|
@click="${this.handleLogout}"
|
|
class="text-[10px] font-bold text-white/20 hover:text-red-400 transition-colors uppercase tracking-widest pb-0.5"
|
|
>
|
|
${translateText("account_modal.clear_session")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private handleEmailInput(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
this.email = target.value;
|
|
}
|
|
|
|
private async handleSubmit() {
|
|
if (!this.email) {
|
|
alert(translateText("account_modal.enter_email_address"));
|
|
return;
|
|
}
|
|
|
|
const success = await sendMagicLink(this.email);
|
|
if (success) {
|
|
alert(
|
|
translateText("account_modal.recovery_email_sent", {
|
|
email: this.email,
|
|
}),
|
|
);
|
|
} else {
|
|
alert(translateText("account_modal.failed_to_send_recovery_email"));
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
private handleGoogleLogin() {
|
|
googleLogin();
|
|
}
|
|
|
|
private async handleLinkGoogle(): Promise<void> {
|
|
// On success linkGoogle navigates to Google; the result comes back as a
|
|
// `link=...` router arg handled in handleLinkResult. A false return means we
|
|
// couldn't start it.
|
|
const started = await linkGoogle();
|
|
if (!started) {
|
|
alert(translateText("account_modal.link_google_failed"));
|
|
}
|
|
}
|
|
|
|
// The Google link callback returns us to #modal=account&link=<result>, so the
|
|
// router reopens this modal with a `link` arg. Surface the outcome, then strip
|
|
// the one-shot param from the URL so a refresh/re-open doesn't replay it.
|
|
private handleLinkResult(args?: Record<string, unknown>): void {
|
|
const link = typeof args?.link === "string" ? args.link : undefined;
|
|
if (link === undefined) return;
|
|
|
|
// replaceState doesn't fire hashchange, so removing the param won't re-route.
|
|
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
params.delete("link");
|
|
const rest = params.toString();
|
|
history.replaceState(
|
|
null,
|
|
"",
|
|
rest ? `#${rest}` : window.location.pathname + window.location.search,
|
|
);
|
|
|
|
// Defer so the modal paints before the (blocking) alert. "cancel" needs no
|
|
// feedback — the user chose to back out.
|
|
if (link === "google") {
|
|
setTimeout(
|
|
() => alert(translateText("account_modal.link_google_success")),
|
|
0,
|
|
);
|
|
} else if (link === "already_linked") {
|
|
setTimeout(
|
|
() => alert(translateText("account_modal.link_google_already_linked")),
|
|
0,
|
|
);
|
|
} else if (link === "error") {
|
|
setTimeout(
|
|
() => alert(translateText("account_modal.link_google_error")),
|
|
0,
|
|
);
|
|
}
|
|
}
|
|
|
|
protected onOpen(args?: Record<string, unknown>): void {
|
|
this.isLoadingUser = true;
|
|
this.handleLinkResult(args);
|
|
|
|
this.refreshCrazyGamesUser();
|
|
|
|
void fetchCosmetics().then((cosmetics) => {
|
|
this.cosmetics = cosmetics;
|
|
this.requestUpdate();
|
|
});
|
|
|
|
void getUserMe()
|
|
.then((userMe) => {
|
|
if (userMe) {
|
|
this.userMeResponse = userMe;
|
|
if (this.userMeResponse?.player?.publicId) {
|
|
this.loadPlayerProfile(this.userMeResponse.player.publicId);
|
|
}
|
|
}
|
|
this.isLoadingUser = false;
|
|
this.requestUpdate();
|
|
})
|
|
.catch((err) => {
|
|
console.warn("Failed to fetch user info in AccountModal.open():", err);
|
|
this.isLoadingUser = false;
|
|
this.requestUpdate();
|
|
});
|
|
this.requestUpdate();
|
|
}
|
|
|
|
protected onClose(): void {
|
|
this.dispatchEvent(
|
|
new CustomEvent("close", { bubbles: true, composed: true }),
|
|
);
|
|
}
|
|
|
|
private async handleLogout() {
|
|
await logOut();
|
|
this.close();
|
|
// Refresh the page after logout to update the UI state
|
|
window.location.reload();
|
|
}
|
|
|
|
private async loadPlayerProfile(publicId: string): Promise<void> {
|
|
try {
|
|
const data = await fetchPlayerById(publicId);
|
|
if (!data) {
|
|
this.requestUpdate();
|
|
return;
|
|
}
|
|
|
|
this.statsTree = data.stats;
|
|
|
|
this.requestUpdate();
|
|
} catch (err) {
|
|
console.warn("Failed to load player data:", err);
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
}
|