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:
evanpelle
2025-08-31 19:09:38 -07:00
committed by GitHub
parent 3574210ebe
commit 35ad6f3abf
15 changed files with 552 additions and 115 deletions
-1
View File
@@ -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

+3
View File
@@ -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

+5
View File
@@ -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

+1
View File
@@ -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

+11
View File
@@ -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",
+316
View File
@@ -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();
}
}
+3 -2
View File
@@ -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,
}),
},
);
+1 -1
View File
@@ -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 ? "☀️" : "🌙"}
+3 -3
View File
@@ -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
View File
@@ -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}`);
-44
View File
@@ -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) {
+98
View File
@@ -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
View File
@@ -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>
+21
View File
@@ -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
View File
@@ -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(),