mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:40:44 +00:00
create account on purchase (#1966)
## Description: When purchasing an item, user will be logged in as their email automatically. * Users can be logged in either via discord or email (the top right button has an email or discord icon depending on which is logged in * Created AccountModal to show current login and has option to log in via Discord or send recovery email * Created TokenLoginModal which is triggered during account recovery or after purchase * Update DiscordUserSchema to * Removed choco pattern key listeners, they were causing NPEs when empty input was provided on forms <img width="408" height="479" alt="Screenshot 2025-08-29 at 5 35 31 PM" src="https://github.com/user-attachments/assets/a2be5556-b534-4279-931b-799d8ece122c" /> support email or discord identity <img width="801" height="351" alt="Screenshot 2025-08-29 at 5 38 59 PM" src="https://github.com/user-attachments/assets/9d18ef8f-a6f8-4c22-b583-c31d9b176467" /> <img width="97" height="83" alt="Screenshot 2025-08-29 at 5 39 51 PM" src="https://github.com/user-attachments/assets/994d7ade-fa02-4adb-a6f8-e929af4089b2" /> <img width="102" height="83" alt="Screenshot 2025-08-29 at 5 40 03 PM" src="https://github.com/user-attachments/assets/f829dd49-996b-479d-9b75-d81092e31da4" /> <img width="59" height="43" alt="Screenshot 2025-08-29 at 5 40 19 PM" src="https://github.com/user-attachments/assets/aacf39e7-2528-463b-95cb-a58bc8c2194b" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"><path fill="#5865f2" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 764 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.019 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 7.00005L10.2 11.65C11.2667 12.45 12.7333 12.45 13.8 11.65L20 7" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user-x"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="18" y1="8" x2="23" y2="13"></line><line x1="23" y1="8" x2="18" y2="13"></line></svg>
|
||||
|
After Width: | Height: | Size: 397 B |
@@ -134,6 +134,17 @@
|
||||
"enables_title": "Enable Settings",
|
||||
"start": "Start Game"
|
||||
},
|
||||
"token_login_modal": {
|
||||
"title": "Logging in...",
|
||||
"logging_in": "Logging in...",
|
||||
"success": "Successfully logged in as {email}!"
|
||||
},
|
||||
"account_modal": {
|
||||
"title": "Account",
|
||||
"logged_in_as": "Logged in as {email}",
|
||||
"logged_in_with_discord": "Logged in with Discord",
|
||||
"recovery_email_sent": "Recovery email sent to {email}"
|
||||
},
|
||||
"map": {
|
||||
"map": "Map",
|
||||
"world": "World",
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { discordLogin, getApiBase, getUserMe, logOut } from "./jwt";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("account-modal")
|
||||
export class AccountModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
@state() private email: string = "";
|
||||
|
||||
private loggedInEmail: string | null = null;
|
||||
private loggedInDiscord: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal
|
||||
id="account-modal"
|
||||
title="${translateText("account_modal.title") || "Account"}"
|
||||
>
|
||||
${this.renderInner()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderInner() {
|
||||
if (this.loggedInDiscord) {
|
||||
return this.renderLoggedInDiscord();
|
||||
} else if (this.loggedInEmail) {
|
||||
return this.renderLoggedInEmail();
|
||||
} else {
|
||||
return this.renderLoginOptions();
|
||||
}
|
||||
}
|
||||
|
||||
private renderLoggedInDiscord() {
|
||||
return html`
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<p class="text-white text-center mb-4">
|
||||
Logged in with Discord as ${this.loggedInDiscord}
|
||||
</p>
|
||||
</div>
|
||||
${this.logoutButton()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLoggedInEmail(): TemplateResult {
|
||||
return html`
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<p class="text-white text-center mb-4">
|
||||
Logged in as ${this.loggedInEmail}
|
||||
</p>
|
||||
</div>
|
||||
${this.logoutButton()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private logoutButton(): TemplateResult {
|
||||
return html`
|
||||
<button
|
||||
@click="${this.handleLogout}"
|
||||
class="px-6 py-3 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLoginOptions() {
|
||||
return html`
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-white mb-4 text-center">
|
||||
Choose your login method
|
||||
</h3>
|
||||
|
||||
<!-- Discord Login Button -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="${this.handleDiscordLogin}"
|
||||
class="w-full px-6 py-3 text-sm font-medium text-white bg-[#5865F2] border border-transparent rounded-md hover:bg-[#4752C4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-colors duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<img
|
||||
src="/images/DiscordLogo.svg"
|
||||
alt="Discord"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
<span
|
||||
>${translateText("main.login_discord") ||
|
||||
"Login with Discord"}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative mb-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-gray-800 text-gray-300">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Recovery -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-white mb-2"
|
||||
>
|
||||
Recover account by email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
.value="${this.email}"
|
||||
@input="${this.handleEmailInput}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="${this.close}"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="${this.handleSubmit}"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleEmailInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
this.email = target.value;
|
||||
}
|
||||
|
||||
private async handleSubmit() {
|
||||
if (!this.email) {
|
||||
alert("Please enter an email address");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBase = getApiBase();
|
||||
const response = await fetch(`${apiBase}/magic-link`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
redirectDomain: window.location.origin,
|
||||
email: this.email,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert(
|
||||
translateText("account_modal.recovery_email_sent", {
|
||||
email: this.email,
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
} else {
|
||||
console.error(
|
||||
"Failed to send recovery email:",
|
||||
response.status,
|
||||
response.statusText,
|
||||
);
|
||||
alert("Failed to send recovery email. Please try again.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending recovery email:", error);
|
||||
alert("Error sending recovery email. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
private handleDiscordLogin() {
|
||||
discordLogin();
|
||||
}
|
||||
|
||||
public async open() {
|
||||
const userMe = await getUserMe();
|
||||
if (userMe) {
|
||||
this.loggedInEmail = userMe.user.email ?? null;
|
||||
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
|
||||
}
|
||||
this.modalEl?.open();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
private async handleLogout() {
|
||||
await logOut();
|
||||
this.close();
|
||||
// Refresh the page after logout to update the UI state
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("account-button")
|
||||
export class AccountButton extends LitElement {
|
||||
@state() private loggedInEmail: string | null = null;
|
||||
@state() private loggedInDiscord: string | null = null;
|
||||
|
||||
@query("account-modal") private recoveryModal: AccountModal;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
document.addEventListener("userMeResponse", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
|
||||
if (customEvent.detail) {
|
||||
const userMeResponse = customEvent.detail as UserMeResponse;
|
||||
if (userMeResponse.user.email) {
|
||||
this.loggedInEmail = userMeResponse.user.email;
|
||||
this.requestUpdate();
|
||||
} else if (userMeResponse.user.discord) {
|
||||
this.loggedInDiscord = userMeResponse.user.discord.id;
|
||||
this.requestUpdate();
|
||||
}
|
||||
} else {
|
||||
// Clear the logged in states when user logs out
|
||||
this.loggedInEmail = null;
|
||||
this.loggedInDiscord = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
let buttonTitle = "";
|
||||
if (this.loggedInEmail) {
|
||||
buttonTitle = translateText("account_modal.logged_in_as", {
|
||||
email: this.loggedInEmail,
|
||||
});
|
||||
} else if (this.loggedInDiscord) {
|
||||
buttonTitle = translateText("account_modal.logged_in_with_discord");
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed top-4 right-4 z-[9999]">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
title="${buttonTitle}"
|
||||
>
|
||||
${this.renderIcon()}
|
||||
</button>
|
||||
</div>
|
||||
<account-modal></account-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIcon() {
|
||||
if (this.loggedInDiscord) {
|
||||
return html`<img
|
||||
src="/images/DiscordLogo.svg"
|
||||
alt="Discord"
|
||||
class="w-6 h-6"
|
||||
/>`;
|
||||
} else if (this.loggedInEmail) {
|
||||
return html`<img
|
||||
src="/images/EmailIcon.svg"
|
||||
alt="Email"
|
||||
class="w-6 h-6"
|
||||
/>`;
|
||||
}
|
||||
return html`<img
|
||||
src="/images/LoggedOutIcon.svg"
|
||||
alt="Logged Out"
|
||||
class="w-6 h-6"
|
||||
/>`;
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.recoveryModal?.open();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas";
|
||||
import { getApiBase, getAuthHeader } from "./jwt";
|
||||
import { getPersistentID } from "./Main";
|
||||
|
||||
export async function fetchPatterns(
|
||||
userMe: UserMeResponse | null,
|
||||
@@ -44,11 +45,11 @@ export async function handlePurchase(pattern: Pattern) {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
authorization: getAuthHeader(),
|
||||
"X-Persistent-Id": getPersistentID(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId: pattern.product.priceId,
|
||||
successUrl: `${window.location.origin}#purchase-completed=true&pattern=${pattern.name}`,
|
||||
cancelUrl: `${window.location.origin}#purchase-completed=false`,
|
||||
hostname: window.location.origin,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export class DarkModeButton extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
title="Toggle Dark Mode"
|
||||
class="absolute top-0 right-0 md:top-[10px] md:right-[10px] border-none bg-none cursor-pointer text-2xl"
|
||||
class="absolute top-0 left-0 md:top-[10px] md:left-[10px] border-none bg-none cursor-pointer text-2xl"
|
||||
@click=${() => this.toggleDarkMode()}
|
||||
>
|
||||
${this.darkMode ? "☀️" : "🌙"}
|
||||
|
||||
@@ -76,7 +76,7 @@ export class LangSelector extends LitElement {
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this; // Use Light DOM if you prefer this
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -87,10 +87,10 @@ export class LangSelector extends LitElement {
|
||||
|
||||
private setupDebugKey() {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key.toLowerCase() === "t") this.debugKeyPressed = true;
|
||||
if (e.key?.toLowerCase() === "t") this.debugKeyPressed = true;
|
||||
});
|
||||
window.addEventListener("keyup", (e) => {
|
||||
if (e.key.toLowerCase() === "t") this.debugKeyPressed = false;
|
||||
if (e.key?.toLowerCase() === "t") this.debugKeyPressed = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+77
-44
@@ -6,6 +6,7 @@ import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./AccountModal";
|
||||
import { joinLobby } from "./ClientGameRunner";
|
||||
import "./DarkModeButton";
|
||||
import { DarkModeButton } from "./DarkModeButton";
|
||||
@@ -25,6 +26,7 @@ import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
import { SendKickPlayerIntentEvent } from "./Transport";
|
||||
import { UserSettingModal } from "./UserSettingModal";
|
||||
import "./UsernameInput";
|
||||
@@ -37,9 +39,8 @@ import {
|
||||
import "./components/NewsButton";
|
||||
import { NewsButton } from "./components/NewsButton";
|
||||
import "./components/baseComponents/Button";
|
||||
import { OButton } from "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { discordLogin, getUserMe, isLoggedIn, logOut } from "./jwt";
|
||||
import { discordLogin, getUserMe, isLoggedIn } from "./jwt";
|
||||
import "./styles.css";
|
||||
|
||||
declare global {
|
||||
@@ -90,6 +91,7 @@ class Client {
|
||||
private publicLobby: PublicLobby;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private patternsModal: TerritoryPatternsModal;
|
||||
private tokenLoginModal: TokenLoginModal;
|
||||
|
||||
constructor() {}
|
||||
|
||||
@@ -142,13 +144,6 @@ class Client {
|
||||
console.warn("Dark mode button element not found");
|
||||
}
|
||||
|
||||
const loginDiscordButton = document.getElementById(
|
||||
"login-discord",
|
||||
) as OButton;
|
||||
const logoutDiscordButton = document.getElementById(
|
||||
"logout-discord",
|
||||
) as OButton;
|
||||
|
||||
this.usernameInput = document.querySelector(
|
||||
"username-input",
|
||||
) as UsernameInput;
|
||||
@@ -221,8 +216,20 @@ class Client {
|
||||
this.patternsModal.open();
|
||||
});
|
||||
|
||||
loginDiscordButton.addEventListener("click", discordLogin);
|
||||
this.tokenLoginModal = document.querySelector(
|
||||
"token-login",
|
||||
) as TokenLoginModal;
|
||||
this.tokenLoginModal instanceof TokenLoginModal;
|
||||
|
||||
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("userMeResponse", {
|
||||
detail: userMeResponse,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = await getServerConfigFromClient();
|
||||
if (!hasAllowedFlare(userMeResponse, config)) {
|
||||
if (userMeResponse === false) {
|
||||
@@ -300,10 +307,6 @@ class Client {
|
||||
return;
|
||||
} else if (userMeResponse === false) {
|
||||
// Not logged in
|
||||
loginDiscordButton.disable = false;
|
||||
loginDiscordButton.hidden = false;
|
||||
loginDiscordButton.translationKey = "main.login_discord";
|
||||
logoutDiscordButton.hidden = true;
|
||||
this.patternsModal.onUserMe(null);
|
||||
} else {
|
||||
// Authorized
|
||||
@@ -311,8 +314,6 @@ class Client {
|
||||
`Your player ID is ${userMeResponse.player.publicId}\n` +
|
||||
"Sharing this ID will allow others to view your game history and stats.",
|
||||
);
|
||||
loginDiscordButton.translationKey = "main.logged_in";
|
||||
loginDiscordButton.hidden = true;
|
||||
this.patternsModal.onUserMe(userMeResponse);
|
||||
}
|
||||
};
|
||||
@@ -322,15 +323,6 @@ class Client {
|
||||
onUserMe(false);
|
||||
} else {
|
||||
// JWT appears to be valid
|
||||
loginDiscordButton.disable = true;
|
||||
loginDiscordButton.translationKey = "main.checking_login";
|
||||
logoutDiscordButton.hidden = false;
|
||||
logoutDiscordButton.addEventListener("click", () => {
|
||||
// Log out
|
||||
logOut();
|
||||
onUserMe(false);
|
||||
});
|
||||
// Look up the discord user object.
|
||||
// TODO: Add caching
|
||||
getUserMe().then(onUserMe);
|
||||
}
|
||||
@@ -416,35 +408,76 @@ class Client {
|
||||
}
|
||||
|
||||
private handleHash() {
|
||||
const { hash } = window.location;
|
||||
|
||||
const alertAndStrip = (message: string) => {
|
||||
alert(message);
|
||||
const strip = () =>
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
|
||||
const alertAndStrip = (message: string) => {
|
||||
alert(message);
|
||||
strip();
|
||||
};
|
||||
|
||||
if (hash.startsWith("#")) {
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
if (params.get("purchase-completed") === "true") {
|
||||
const patternName = params.get("pattern");
|
||||
if (patternName === null) {
|
||||
alert("Something went wrong. Please contact support.");
|
||||
console.error("purchase-completed=true but no pattern name");
|
||||
return;
|
||||
}
|
||||
alertAndStrip(`purchase succeeded: ${patternName}`);
|
||||
this.userSettings.setSelectedPatternName(patternName ?? undefined);
|
||||
this.patternsModal.refresh();
|
||||
return;
|
||||
} else if (params.get("purchase-completed") === "false") {
|
||||
const hash = window.location.hash;
|
||||
|
||||
// Decode the hash first to handle encoded characters
|
||||
const decodedHash = decodeURIComponent(hash);
|
||||
const params = new URLSearchParams(decodedHash.split("?")[1] || "");
|
||||
|
||||
// Handle different hash sections
|
||||
if (decodedHash.startsWith("#purchase-completed")) {
|
||||
// Parse params after the ?
|
||||
const status = params.get("status");
|
||||
|
||||
if (status !== "true") {
|
||||
alertAndStrip("purchase failed");
|
||||
return;
|
||||
}
|
||||
const lobbyId = params.get("join");
|
||||
|
||||
const patternName = params.get("pattern");
|
||||
if (!patternName) {
|
||||
alert("Something went wrong. Please contact support.");
|
||||
console.error("purchase-completed but no pattern name");
|
||||
return;
|
||||
}
|
||||
|
||||
this.userSettings.setSelectedPatternName(patternName);
|
||||
const token = params.get("login-token");
|
||||
|
||||
if (token) {
|
||||
strip();
|
||||
window.addEventListener("beforeunload", () => {
|
||||
// The page reloads after token login, so we need to save the pattern name
|
||||
// in case it is unset during reload.
|
||||
this.userSettings.setSelectedPatternName(patternName);
|
||||
});
|
||||
this.tokenLoginModal.open(token);
|
||||
} else {
|
||||
alertAndStrip(`purchase succeeded: ${patternName}`);
|
||||
this.patternsModal.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (decodedHash.startsWith("#token-login")) {
|
||||
const token = params.get("token-login");
|
||||
|
||||
if (!token) {
|
||||
alertAndStrip(
|
||||
`login failed! Please try again later or contact support.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
strip();
|
||||
this.tokenLoginModal.open(token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decodedHash.startsWith("#join")) {
|
||||
const lobbyId = params.get("lobby");
|
||||
if (lobbyId && ID.safeParse(lobbyId).success) {
|
||||
this.joinModal.open(lobbyId);
|
||||
console.log(`joining lobby ${lobbyId}`);
|
||||
|
||||
@@ -21,9 +21,6 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
@state() private selectedPattern: Pattern | null;
|
||||
|
||||
@state() private keySequence: string[] = [];
|
||||
@state() private showChocoPattern = false;
|
||||
|
||||
private patterns: Map<string, Pattern> = new Map();
|
||||
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
@@ -34,11 +31,6 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
super();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | null) {
|
||||
if (userMeResponse === null) {
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
@@ -52,38 +44,6 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const nextSequence = [...this.keySequence, key].slice(-5);
|
||||
this.keySequence = nextSequence;
|
||||
|
||||
if (nextSequence.join("") === "choco") {
|
||||
this.triggerChocoEasterEgg();
|
||||
this.keySequence = [];
|
||||
}
|
||||
};
|
||||
|
||||
private triggerChocoEasterEgg() {
|
||||
console.log("🍫 Choco pattern unlocked!");
|
||||
this.showChocoPattern = true;
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "easter-egg-popup";
|
||||
popup.textContent = "🎉 You unlocked the Choco pattern!";
|
||||
document.body.appendChild(popup);
|
||||
|
||||
setTimeout(() => {
|
||||
popup.remove();
|
||||
}, 5000);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -91,8 +51,6 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
for (const [name, pattern] of this.patterns) {
|
||||
if (!this.showChocoPattern && name === "choco") continue;
|
||||
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
@@ -131,13 +89,11 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
public async open() {
|
||||
this.isActive = true;
|
||||
await this.refresh();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isActive = false;
|
||||
this.modalEl?.close();
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
private selectPattern(pattern: Pattern | null) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators.js";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { tokenLogin } from "./jwt";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("token-login")
|
||||
export class TokenLoginModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
private isAttemptingLogin = false;
|
||||
|
||||
private retryInterval: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
private token: string | null = null;
|
||||
|
||||
private email: string | null = null;
|
||||
|
||||
private attemptCount = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal
|
||||
id="token-login-modal"
|
||||
title="${translateText("token_login_modal.title")}"
|
||||
>
|
||||
${this.email ? this.loginSuccess(this.email) : this.loggingIn()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private loggingIn() {
|
||||
return html` <p>${translateText("token_login_modal.logging_in")}</p> `;
|
||||
}
|
||||
|
||||
private loginSuccess(email: string) {
|
||||
return html`<p>
|
||||
${translateText("token_login_modal.success", {
|
||||
email,
|
||||
})}
|
||||
</p> `;
|
||||
}
|
||||
|
||||
public async open(token: string) {
|
||||
this.token = token;
|
||||
this.modalEl?.open();
|
||||
this.retryInterval = setInterval(() => this.tryLogin(), 3000);
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.token = null;
|
||||
clearInterval(this.retryInterval);
|
||||
this.attemptCount = 0;
|
||||
this.modalEl?.close();
|
||||
this.isAttemptingLogin = false;
|
||||
}
|
||||
|
||||
private async tryLogin() {
|
||||
if (this.isAttemptingLogin) {
|
||||
return;
|
||||
}
|
||||
if (this.attemptCount > 3) {
|
||||
this.close();
|
||||
alert("Login failed. Please try again later.");
|
||||
return;
|
||||
}
|
||||
this.attemptCount++;
|
||||
this.isAttemptingLogin = true;
|
||||
if (this.token === null) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.email = await tokenLogin(this.token);
|
||||
if (!this.email) {
|
||||
return;
|
||||
}
|
||||
clearInterval(this.retryInterval);
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
this.requestUpdate();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.isAttemptingLogin = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-14
@@ -195,20 +195,7 @@
|
||||
<!-- Main container with responsive padding -->
|
||||
<main class="flex justify-center flex-grow">
|
||||
<div class="container pt-12">
|
||||
<o-button
|
||||
id="login-discord"
|
||||
title="Initializing..."
|
||||
disable="true"
|
||||
block
|
||||
></o-button>
|
||||
|
||||
<o-button
|
||||
id="logout-discord"
|
||||
title="Log out"
|
||||
translationKey="main.log_out"
|
||||
visible="false"
|
||||
block
|
||||
></o-button>
|
||||
<token-login class="w-[20%] md:w-[15%]"></token-login>
|
||||
|
||||
<div class="container__row">
|
||||
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
|
||||
@@ -389,6 +376,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<account-button></account-button>
|
||||
<!-- Game modals and overlays -->
|
||||
<single-player-modal></single-player-modal>
|
||||
<host-lobby-modal></host-lobby-modal>
|
||||
|
||||
@@ -71,6 +71,27 @@ export function discordLogin() {
|
||||
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
|
||||
}
|
||||
|
||||
export async function tokenLogin(token: string): Promise<string | null> {
|
||||
const response = await fetch(
|
||||
`${getApiBase()}/login/token?login-token=${token}`,
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
console.error("Token login failed", response);
|
||||
return null;
|
||||
}
|
||||
const json = await response.json();
|
||||
const { jwt, email } = json;
|
||||
const payload = decodeJwt(jwt);
|
||||
const result = TokenPayloadSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
console.error("Invalid token", result.error, result.error.message);
|
||||
return null;
|
||||
}
|
||||
clearToken();
|
||||
localStorage.setItem("token", jwt);
|
||||
return email;
|
||||
}
|
||||
|
||||
export function getAuthHeader(): string {
|
||||
const token = getToken();
|
||||
if (!token) return "";
|
||||
|
||||
+11
-6
@@ -31,14 +31,19 @@ export const TokenPayloadSchema = z.object({
|
||||
});
|
||||
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
|
||||
|
||||
export const DiscordUserSchema = z.object({
|
||||
id: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
username: z.string(),
|
||||
global_name: z.string().nullable(),
|
||||
discriminator: z.string(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UserMeResponseSchema = z.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
username: z.string(),
|
||||
global_name: z.string().nullable(),
|
||||
discriminator: z.string(),
|
||||
locale: z.string().optional(),
|
||||
discord: DiscordUserSchema.optional(),
|
||||
email: z.string().optional(),
|
||||
}),
|
||||
player: z.object({
|
||||
publicId: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user