indentity store instead

This commit is contained in:
Ryan Barlow
2026-05-26 18:54:58 +01:00
parent fea01e63e9
commit e2901e72dd
10 changed files with 535 additions and 372 deletions
+2 -1
View File
@@ -662,7 +662,8 @@
"tag_too_short": "Clan tag must be 2-5 alphanumeric characters.",
"tag_too_long": "Clan tag cannot exceed 5 characters.",
"tag_invalid_chars": "Clan tag can only contain letters and numbers.",
"tag_not_member": "Join the {tag} clan before using its tag."
"tag_not_member": "Join the {tag} clan before using its tag.",
"tag_checking": "Checking clan tag…"
},
"host_modal": {
"title": "Create Private Lobby",
+51 -157
View File
@@ -1,17 +1,18 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { customElement } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { sanitizeClanTag } from "../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
MIN_CLAN_TAG_LENGTH,
validateClanTag,
} from "../core/validations/username";
import { getUserMe } from "./Api";
import { fetchClanExists } from "./ClanApi";
const CLAN_OWNERSHIP_DEBOUNCE_MS = 400;
const clanTagKey = "clanTag";
import { IdentityReadyController } from "./identity/IdentityReadyController";
import {
awaitIdentityReady,
getClanTagForSubmit,
initIdentityFromStorage,
revalidateIdentityTranslations,
setClanTag,
} from "./identity/IdentityStore";
interface LangSelectorLike {
currentLang?: string;
@@ -21,16 +22,7 @@ interface LangSelectorLike {
@customElement("clan-tag-input")
export class ClanTagInput extends LitElement {
@state() private clanTag: string = "";
@property({ type: String }) validationError: string = "";
private formatError: string = "";
private ownershipError: string = "";
private checkCounter: number = 0;
private checkTimer: ReturnType<typeof setTimeout> | null = null;
private currentCheck: Promise<void> = Promise.resolve();
private resolveDebounce: (() => void) | null = null;
private identity = new IdentityReadyController(this);
private lastTranslatedLang: string | null = null;
createRenderRoot() {
@@ -38,42 +30,22 @@ export class ClanTagInput extends LitElement {
}
public isValid(): boolean {
return this.formatError === "" && this.ownershipError === "";
return this.identity.state.clanTag.valid;
}
public getValue(): string | null {
return this.isValid() &&
this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
validateClanTag(this.clanTag).isValid
? this.clanTag
: null;
return getClanTagForSubmit();
}
connectedCallback() {
super.connectedCallback();
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
// No user input to coalesce on initial mount — fire the ownership check
// immediately instead of paying the debounce delay.
this.validate({ immediate: true });
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.checkTimer !== null) {
clearTimeout(this.checkTimer);
this.checkTimer = null;
}
this.checkCounter++; // cancel any in-flight async check
if (this.resolveDebounce) this.resolveDebounce();
this.resolveDebounce = null;
this.currentCheck = Promise.resolve();
initIdentityFromStorage();
}
protected updated(): void {
// Re-validate when translations finish loading so the initial error
// (which may have been built from raw keys) gets re-translated.
if (!this.validationError) return;
// Re-translate any error string when the active language changes — the
// store caches the i18n key for ownership errors, but format errors are
// raw translated strings that need to be regenerated.
const ls = document.querySelector<LangSelectorLike & Element>(
"lang-selector",
);
@@ -81,38 +53,62 @@ export class ClanTagInput extends LitElement {
const hasTranslations = ls?.translations ?? ls?.defaultTranslations;
if (hasTranslations && lang && lang !== this.lastTranslatedLang) {
this.lastTranslatedLang = lang;
this.validate();
revalidateIdentityTranslations();
}
}
render() {
const { value, error } = this.identity.state.clanTag;
const checking = this.identity.validating;
const displayError = this.translatedError(error);
return html`
<div class="relative flex items-center h-full">
<input
type="text"
.value=${this.clanTag}
.value=${value}
@input=${this.handleInput}
placeholder="${translateText("username.tag")}"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
aria-busy=${checking ? "true" : "false"}
aria-invalid=${displayError ? "true" : "false"}
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
${this.validationError
${checking
? html`<span
class="absolute right-1 top-1/2 -translate-y-1/2 w-3 h-3 border-2 border-white/30 border-t-white/80 rounded-full animate-spin pointer-events-none"
aria-hidden="true"
></span>`
: null}
${displayError
? html`<div
id="clan-tag-validation-error"
class="absolute top-full left-0 z-50 mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg whitespace-nowrap"
>
${this.validationError}
${displayError}
</div>`
: null}
</div>
`;
}
private translatedError(raw: string): string {
if (!raw) return "";
// Ownership errors are stored as i18n keys (with optional tag param);
// format errors are already-translated strings from validateClanTag.
if (raw === "username.tag_not_member") {
return translateText(raw, { tag: this.identity.state.clanTag.value });
}
return raw;
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeClanTag(input.value);
if (input.value.toUpperCase() !== sanitized) {
const raw = input.value;
const upper = raw.toUpperCase();
setClanTag(raw);
const sanitized = this.identity.state.clanTag.value;
if (upper !== sanitized) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
@@ -124,120 +120,18 @@ export class ClanTagInput extends LitElement {
);
}
input.value = sanitized;
this.clanTag = sanitized;
this.validate();
}
private validate(options: { immediate?: boolean } = {}) {
const tag = this.clanTag;
const result = validateClanTag(tag);
this.formatError = result.isValid ? "" : (result.error ?? "");
// Cancel any pending/in-flight ownership check. checkCounter++ marks
// any in-flight async work obsolete (stillCurrent() in checkOwnership
// returns false). Resolve the prior debounce so awaitValidation()
// callers don't hang on the cancelled chain.
if (this.checkTimer !== null) clearTimeout(this.checkTimer);
this.checkTimer = null;
this.checkCounter++;
if (this.resolveDebounce) this.resolveDebounce();
this.resolveDebounce = null;
if (!result.isValid || tag.length === 0) {
// Nothing to ask the server about — clear any old ownership error
// and wipe the stored tag so a reload doesn't restore a stale value
// that no longer matches the current (invalid/empty) input.
this.ownershipError = "";
localStorage.setItem(clanTagKey, "");
this.currentCheck = Promise.resolve();
} else {
// Snapshot the generation so cancelled debounce chains skip the API
// round-trip entirely — checkOwnership's internal stillCurrent() only
// fires after getUserMe() has already returned.
const generation = this.checkCounter;
const run = (): Promise<void> => {
if (generation !== this.checkCounter) return Promise.resolve();
return this.checkOwnership(tag);
};
if (options.immediate) {
// Initial mount / non-typing trigger — no input to coalesce, run now.
this.currentCheck = run();
} else {
const debounce = new Promise<void>((resolve) => {
this.resolveDebounce = resolve;
});
this.checkTimer = setTimeout(() => {
this.checkTimer = null;
const resolve = this.resolveDebounce;
this.resolveDebounce = null;
resolve?.();
}, CLAN_OWNERSHIP_DEBOUNCE_MS);
this.currentCheck = debounce.then(run);
}
}
this.refreshError();
}
// Resolves once the latest validate() chain finishes — either the debounce
// timer + ownership check, or immediately if the input is invalid/empty.
// Resolves once any in-flight async ownership check settles. Returns
// immediately when nothing is in flight.
public async awaitValidation(): Promise<void> {
let last: Promise<void> | undefined;
while (this.currentCheck !== last) {
last = this.currentCheck;
await last;
}
}
// Are you a member? If not, only accept when the API confirms the clan
// doesn't exist (fictional). Inconclusive results (null/timeout) reject so
// the client matches the server's fail-closed enforcement — otherwise the
// client would let the modal open with a tag the server later drops.
private async checkOwnership(tag: string) {
const checkId = this.checkCounter;
const stillCurrent = () =>
checkId === this.checkCounter && this.clanTag === tag;
const me = await getUserMe();
if (!stillCurrent()) return;
const myTags = me
? (me.player.clans ?? []).map((c) => c.tag.toUpperCase())
: [];
if (!myTags.includes(tag.toUpperCase())) {
const exists = await fetchClanExists(tag);
if (!stillCurrent()) return;
if (exists !== false) {
this.reject(tag);
return;
}
}
this.accept(tag);
}
private accept(tag: string) {
this.ownershipError = "";
localStorage.setItem(clanTagKey, tag);
this.refreshError();
}
private reject(tag: string) {
this.ownershipError = translateText("username.tag_not_member", { tag });
localStorage.removeItem(clanTagKey);
this.refreshError();
}
private refreshError() {
const next = this.formatError || this.ownershipError;
if (this.validationError !== next) {
this.validationError = next;
this.requestUpdate();
}
await awaitIdentityReady();
}
public showValidationFeedback() {
const message =
this.validationError || translateText("username.tag_invalid_chars");
this.translatedError(this.identity.state.clanTag.error) ||
translateText("username.tag_invalid_chars");
window.dispatchEvent(
new CustomEvent("show-message", {
detail: { message, color: "red", duration: 2500 },
+33 -31
View File
@@ -10,16 +10,15 @@ import {
Trios,
} from "../core/game/Game";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import { ClanTagInput } from "./ClanTagInput";
import "./components/IOSAddToHomeScreenBanner";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { HostLobbyModal } from "./HostLobbyModal";
import { IdentityReadyController } from "./identity/IdentityReadyController";
import { JoinLobbyModal } from "./JoinLobbyModal";
import { PublicLobbySocket } from "./LobbySocket";
import { JoinLobbyEvent } from "./Main";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import { UsernameInput } from "./UsernameInput";
import {
calculateServerTimeOffset,
getMapName,
@@ -42,27 +41,20 @@ export class GameModeSelector extends LitElement {
this.handleLobbiesUpdate(lobbies),
);
private identity = new IdentityReadyController(this);
createRenderRoot() {
return this;
}
/**
* Validates username and clan tag inputs and shows error messages if invalid.
* Awaits any pending clan-tag ownership check so the gate doesn't pass while
* an async validation is still in flight.
*/
private async validateIdentity(): Promise<boolean> {
const clanTagInput = document.querySelector(
"clan-tag-input",
) as ClanTagInput | null;
if (clanTagInput) {
await clanTagInput.awaitValidation();
if (!clanTagInput.validateOrShowError()) return false;
private identityTooltip(): string {
if (this.identity.validating) {
return translateText("username.tag_checking");
}
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput | null;
return usernameInput ? usernameInput.validateOrShowError() : true;
const { username, clanTag } = this.identity.state;
if (!username.valid) return username.error;
if (!clanTag.valid) return clanTag.error;
return "";
}
connectedCallback() {
@@ -238,25 +230,25 @@ export class GameModeSelector extends LitElement {
return this.renderLobbyCard(lobby, this.getLobbyTitle(lobby));
}
private openRankedMenu = async () => {
if (!(await this.validateIdentity())) return;
private openRankedMenu = () => {
if (!this.identity.ready) return;
window.showPage?.("page-ranked");
};
private openSinglePlayerModal = async () => {
if (!(await this.validateIdentity())) return;
private openSinglePlayerModal = () => {
if (!this.identity.ready) return;
(
document.querySelector("single-player-modal") as SinglePlayerModal
)?.open();
};
private openHostLobby = async () => {
if (!(await this.validateIdentity())) return;
private openHostLobby = () => {
if (!this.identity.ready) return;
(document.querySelector("host-lobby-modal") as HostLobbyModal)?.open();
};
private openJoinLobby = async () => {
if (!(await this.validateIdentity())) return;
private openJoinLobby = () => {
if (!this.identity.ready) return;
(document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open();
};
@@ -265,10 +257,15 @@ export class GameModeSelector extends LitElement {
onClick: () => void,
bgClass: string = CARD_BG,
) {
const disabled = !this.identity.ready;
const tip = this.identityTooltip();
return html`
<button
@click=${onClick}
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center"
?disabled=${disabled}
title=${disabled && tip ? tip : ""}
aria-busy=${this.identity.validating ? "true" : "false"}
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:brightness-100"
>
${title}
</button>
@@ -311,10 +308,15 @@ export class GameModeSelector extends LitElement {
modifierLabels.sort((a, b) => a.length - b.length);
}
const disabled = !this.identity.ready;
const tip = this.identityTooltip();
return html`
<button
@click=${() => this.validateAndJoin(lobby)}
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] bg-surface hover:shadow-[var(--shadow-lobby-card-hover)]"
@click=${() => this.joinPublicLobby(lobby)}
?disabled=${disabled}
title=${disabled && tip ? tip : ""}
aria-busy=${this.identity.validating ? "true" : "false"}
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] bg-surface hover:shadow-[var(--shadow-lobby-card-hover)] disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
>
<!-- Image clipped separately so overflow-hidden doesn't block absolute children -->
<div
@@ -390,8 +392,8 @@ export class GameModeSelector extends LitElement {
`;
}
private async validateAndJoin(lobby: PublicGameInfo) {
if (!(await this.validateIdentity())) return;
private joinPublicLobby(lobby: PublicGameInfo) {
if (!this.identity.ready) return;
this.dispatchEvent(
new CustomEvent("join-lobby", {
+8 -4
View File
@@ -29,6 +29,7 @@ import "./components/LobbyPlayerView";
import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { IdentityReadyController } from "./identity/IdentityReadyController";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
@@ -94,6 +95,7 @@ export class HostLobbyModal extends BaseModal {
private nationsUpdateTimer: number | null = null;
private mapLoader = terrainMapFileLoader;
private userSettings = new UserSettings();
private identity = new IdentityReadyController(this);
private leaveLobbyOnClose = true;
@@ -417,10 +419,12 @@ export class HostLobbyModal extends BaseModal {
variant="primary"
width="block"
size="lg"
.title=${this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")}
?disable=${this.clients.length < 2}
.title=${this.identity.validating
? translateText("username.tag_checking")
: this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")}
?disable=${this.clients.length < 2 || !this.identity.ready}
@click=${this.startGame}
></o-button>
</div>
+15 -32
View File
@@ -22,7 +22,6 @@ import { getUserMe, invalidateUserMe } from "./Api";
import { userAuth } from "./Auth";
import "./ClanModal";
import "./ClanTagInput";
import { ClanTagInput } from "./ClanTagInput";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
@@ -61,13 +60,18 @@ import {
} from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
import "./UsernameInput";
import { genAnonUsername, UsernameInput } from "./UsernameInput";
import { genAnonUsername } from "./UsernameInput";
import {
getDiscordAvatarUrl,
incrementGamesPlayed,
isInIframe,
translateText,
} from "./Utils";
import {
awaitIdentityReady,
getClanTagForSubmit,
getUsernameForSubmit,
} from "./identity/IdentityStore";
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";
import "./components/DesktopNavBar";
@@ -252,8 +256,6 @@ class Client {
private currentUrl: string | null = null;
private usernameInput: UsernameInput | null = null;
private clanTagInput: ClanTagInput | null = null;
private flagInput: FlagInput | null = null;
private hostModal: HostPrivateLobbyModal;
@@ -357,20 +359,6 @@ class Client {
console.warn("Flag input element not found");
}
this.usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (!this.usernameInput) {
console.warn("Username input element not found");
}
this.clanTagInput = document.querySelector(
"clan-tag-input",
) as ClanTagInput;
if (!this.clanTagInput) {
console.warn("Clan tag input element not found");
}
this.gameModeSelector = document.querySelector(
"game-mode-selector",
) as GameModeSelector;
@@ -824,11 +812,13 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
const lobby = event.detail;
this.mostRecentJoinEvent = event.timeStamp;
if (this.clanTagInput) {
await this.clanTagInput.awaitValidation();
if (!this.clanTagInput.validateOrShowError()) return;
}
if (this.usernameInput && !this.usernameInput.validateOrShowError()) {
// Final identity gate. Callers (play buttons, modals) already disable
// themselves until identity is ready, so this is a defense-in-depth check
// for stray dispatches — bail silently rather than show a toast since
// the inputs already render their own inline errors.
const ready = await awaitIdentityReady();
if (!ready) {
console.warn("join-lobby blocked: identity not ready");
return;
}
@@ -854,8 +844,8 @@ class Client {
gameID: lobby.gameID,
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
playerClanTag: this.clanTagInput?.getValue() ?? null,
playerName: getUsernameForSubmit() || genAnonUsername(),
playerClanTag: getClanTagForSubmit(),
playerRole,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
@@ -872,13 +862,6 @@ class Client {
this.lobbyHandle.prestart.then(() => {
console.log("Closing modals");
document.getElementById("settings-button")?.classList.add("hidden");
if (this.usernameInput) {
// fix edge case where username-validation-error is re-rendered and hidden tag removed
this.usernameInput.validationError = "";
}
if (this.clanTagInput) {
this.clanTagInput.validationError = "";
}
document
.getElementById("clan-tag-validation-error")
?.classList.add("hidden");
+14 -23
View File
@@ -14,7 +14,6 @@ import {
import { TeamCountConfig } from "../core/Schemas";
import { generateID } from "../core/Util";
import { hasLinkedAccount } from "./Api";
import { ClanTagInput } from "./ClanTagInput";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
@@ -23,8 +22,12 @@ import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { IdentityReadyController } from "./identity/IdentityReadyController";
import {
getClanTagForSubmit,
getUsernameForSubmit,
} from "./identity/IdentityStore";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
import {
getBotsForCompactMap,
getNationsForCompactMap,
@@ -99,6 +102,7 @@ export class SinglePlayerModal extends BaseModal {
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
@state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes;
private identity = new IdentityReadyController(this);
private mapLoader = terrainMapFileLoader;
connectedCallback() {
@@ -355,7 +359,12 @@ export class SinglePlayerModal extends BaseModal {
variant="primary"
width="block"
size="lg"
translationKey="single_modal.start"
translationKey=${
this.identity.validating
? "username.tag_checking"
: "single_modal.start"
}
?disable=${!this.identity.ready}
@click=${this.startGame}
></o-button>
</div>
@@ -644,24 +653,6 @@ export class SinglePlayerModal extends BaseModal {
const clientID = generateID();
const gameID = generateID();
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
const clanTagInput = document.querySelector(
"clan-tag-input",
) as ClanTagInput | null;
// Validate identity before dispatching/closing so a failed clan-tag
// ownership check keeps the modal open with the user's configured game
// settings intact rather than silently dropping them.
if (clanTagInput) {
await clanTagInput.awaitValidation();
if (!clanTagInput.validateOrShowError()) return;
}
if (usernameInput && !usernameInput.validateOrShowError()) {
return;
}
await crazyGamesSDK.requestMidgameAd();
this.dispatchEvent(
@@ -673,8 +664,8 @@ export class SinglePlayerModal extends BaseModal {
players: [
{
clientID,
username: usernameInput.getUsername(),
clanTag: clanTagInput?.getValue() ?? null,
username: getUsernameForSubmit(),
clanTag: getClanTagForSubmit(),
cosmetics: await getPlayerCosmetics(),
},
],
+46 -74
View File
@@ -1,12 +1,18 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { customElement } from "lit/decorators.js";
import { generateCryptoRandomUUID, translateText } from "../client/Utils";
import {
MAX_USERNAME_LENGTH,
MIN_USERNAME_LENGTH,
validateUsername,
} from "../core/validations/username";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { IdentityReadyController } from "./identity/IdentityReadyController";
import {
getUsernameForSubmit,
initIdentityFromStorage,
revalidateIdentityTranslations,
setUsername,
} from "./identity/IdentityStore";
interface LangSelectorLike {
currentLang?: string;
@@ -14,100 +20,84 @@ interface LangSelectorLike {
defaultTranslations?: Record<string, string>;
}
const usernameKey: string = "username";
@customElement("username-input")
export class UsernameInput extends LitElement {
@state() private baseUsername: string = "";
@property({ type: String }) validationError: string = "";
private _isValid: boolean = true;
private _lastValidatedLang: string | null = null;
private identity = new IdentityReadyController(this);
private lastTranslatedLang: string | null = null;
createRenderRoot() {
// Disable shadow DOM to allow Tailwind classes to work
return this;
}
public getUsername(): string {
return this.baseUsername.trim();
return getUsernameForSubmit();
}
public isValid(): boolean {
return this.identity.state.username.valid;
}
connectedCallback() {
super.connectedCallback();
this.loadStoredUsername();
initIdentityFromStorage();
// Fall back to an anonymous handle the first time a user shows up with
// nothing in storage, so the field isn't empty (which would fail
// validation immediately and block play).
if (getUsernameForSubmit().length === 0) {
setUsername(genAnonUsername());
}
crazyGamesSDK.getUsername().then((username) => {
if (username) {
this.baseUsername = username;
this.validateAndStore();
}
if (username) setUsername(username);
});
crazyGamesSDK.addAuthListener((user) => {
if (user) {
this.baseUsername = user.username;
this.validateAndStore();
}
if (user) setUsername(user.username);
});
}
protected updated(): void {
// Re-validate when translations become available or language changes,
// since initial validation may run before translations are loaded.
if (this.validationError) {
const langSelector = document.querySelector<LangSelectorLike & Element>(
"lang-selector",
);
const lang = langSelector?.currentLang;
const hasTranslations =
langSelector?.translations ?? langSelector?.defaultTranslations;
if (hasTranslations && lang && lang !== this._lastValidatedLang) {
this._lastValidatedLang = lang;
this.validateAndStore();
}
}
}
private loadStoredUsername() {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
this.baseUsername = storedUsername;
this.validateAndStore();
} else {
this.baseUsername = genAnonUsername();
this.validateAndStore();
const ls = document.querySelector<LangSelectorLike & Element>(
"lang-selector",
);
const lang = ls?.currentLang;
const hasTranslations = ls?.translations ?? ls?.defaultTranslations;
if (hasTranslations && lang && lang !== this.lastTranslatedLang) {
this.lastTranslatedLang = lang;
revalidateIdentityTranslations();
}
}
render() {
const { value, error } = this.identity.state.username;
return html`
<div class="relative w-full h-full">
<input
type="text"
.value=${this.baseUsername}
@input=${this.handleUsernameChange}
.value=${value}
@input=${this.handleInput}
placeholder="${translateText("username.enter_username")}"
minlength="${MIN_USERNAME_LENGTH}"
maxlength="${MAX_USERNAME_LENGTH}"
aria-invalid=${error ? "true" : "false"}
class="w-full h-full border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
/>
${this.validationError
${error
? html`<div
id="username-validation-error"
class="absolute top-full left-0 z-50 w-full mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg"
>
${this.validationError}
${error}
</div>`
: null}
</div>
`;
}
private handleUsernameChange(e: Event) {
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
const originalValue = input.value;
const val = originalValue.replace(/[[\]]/g, "");
if (originalValue !== val) {
input.value = val;
const stripped = originalValue.replace(/[[\]]/g, "");
if (originalValue !== stripped) {
input.value = stripped;
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
@@ -118,29 +108,13 @@ export class UsernameInput extends LitElement {
}),
);
}
this.baseUsername = val;
this.validateAndStore();
}
private validateAndStore() {
const trimmedBase = this.getUsername();
const result = validateUsername(trimmedBase);
this._isValid = result.isValid;
if (result.isValid) {
localStorage.setItem(usernameKey, trimmedBase);
this.validationError = "";
} else {
this.validationError = result.error ?? "";
}
}
public isValid(): boolean {
return this._isValid;
setUsername(stripped);
}
public showValidationFeedback(): void {
const message =
this.validationError || translateText("username.invalid_chars");
this.identity.state.username.error ||
translateText("username.invalid_chars");
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
@@ -153,9 +127,7 @@ export class UsernameInput extends LitElement {
}
public validateOrShowError(): boolean {
if (this.isValid()) {
return true;
}
if (this.isValid()) return true;
this.showValidationFeedback();
return false;
}
@@ -0,0 +1,48 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import {
awaitIdentityReady,
getIdentityState,
subscribeIdentity,
type IdentityState,
} from "./IdentityStore";
// Subscribes a Lit host to the identity store and triggers a re-render
// whenever the ready/validating state changes. Lets play buttons bind
// `?disabled=${!identity.ready}` without bespoke wiring per consumer.
export class IdentityReadyController implements ReactiveController {
private unsubscribe: (() => void) | null = null;
private snapshot: IdentityState;
constructor(private readonly host: ReactiveControllerHost) {
this.host.addController(this);
this.snapshot = getIdentityState();
}
hostConnected(): void {
this.unsubscribe = subscribeIdentity((state) => {
this.snapshot = state;
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
get ready(): boolean {
return this.snapshot.ready;
}
get validating(): boolean {
return this.snapshot.clanTagChecking;
}
get state(): IdentityState {
return this.snapshot;
}
async awaitReady(): Promise<boolean> {
return awaitIdentityReady();
}
}
+264
View File
@@ -0,0 +1,264 @@
import { sanitizeClanTag } from "../../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
MIN_CLAN_TAG_LENGTH,
validateClanTag,
validateUsername,
} from "../../core/validations/username";
import { getUserMe } from "../Api";
import { fetchClanExists } from "../ClanApi";
const CLAN_OWNERSHIP_DEBOUNCE_MS = 400;
const CLAN_TAG_KEY = "clanTag";
const USERNAME_KEY = "username";
export type IdentityField<T> = {
value: T;
valid: boolean;
error: string;
};
export type IdentityState = {
username: IdentityField<string>;
clanTag: IdentityField<string>;
clanTagChecking: boolean;
ready: boolean;
};
type Listener = (state: IdentityState) => void;
const listeners = new Set<Listener>();
const state: IdentityState = {
username: { value: "", valid: false, error: "" },
clanTag: { value: "", valid: true, error: "" },
clanTagChecking: false,
ready: false,
};
let lastInput: { username: string; clanTag: string } = {
username: "",
clanTag: "",
};
function recomputeReady() {
state.ready =
state.username.valid && state.clanTag.valid && !state.clanTagChecking;
}
function emit() {
recomputeReady();
for (const listener of listeners) listener(state);
}
export function subscribeIdentity(listener: Listener): () => void {
listeners.add(listener);
listener(state);
return () => {
listeners.delete(listener);
};
}
export function getIdentityState(): IdentityState {
return state;
}
export function getUsernameForSubmit(): string {
return state.username.value;
}
// Mirrors the legacy ClanTagInput.getValue contract: only emit a non-null
// value when the tag is valid AND meets length AND format. Empty / pending /
// failed states submit as null so the server falls back to "no tag".
export function getClanTagForSubmit(): string | null {
// Don't submit a tag while the ownership check is in flight — callers
// either gate on `state.ready` first or await `awaitIdentityReady()`.
if (state.clanTagChecking) return null;
const { value, valid } = state.clanTag;
if (!valid) return null;
if (value.length < MIN_CLAN_TAG_LENGTH) return null;
if (value.length > MAX_CLAN_TAG_LENGTH) return null;
if (!validateClanTag(value).isValid) return null;
return value;
}
export function setUsername(raw: string) {
lastInput.username = raw;
const trimmed = raw.trim();
const result = validateUsername(trimmed);
state.username = {
value: trimmed,
valid: result.isValid,
error: result.isValid ? "" : (result.error ?? ""),
};
if (result.isValid) {
localStorage.setItem(USERNAME_KEY, trimmed);
}
emit();
}
let clanCheckCounter = 0;
let clanCheckTimer: ReturnType<typeof setTimeout> | null = null;
let resolveDebounce: (() => void) | null = null;
let currentCheck: Promise<void> = Promise.resolve();
export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
lastInput.clanTag = raw;
const tag = sanitizeClanTag(raw);
const result = validateClanTag(tag);
// Cancel any pending/in-flight ownership work. checkCounter++ marks stale
// chains; resolving the prior debounce lets awaitReady() callers unblock.
if (clanCheckTimer !== null) clearTimeout(clanCheckTimer);
clanCheckTimer = null;
clanCheckCounter++;
if (resolveDebounce) resolveDebounce();
resolveDebounce = null;
state.clanTag = {
value: tag,
valid: result.isValid,
error: result.isValid ? "" : (result.error ?? ""),
};
if (!result.isValid || tag.length === 0) {
// Nothing to ask the server about. Wipe the stored tag so a reload
// doesn't restore a stale value that no longer matches input.
state.clanTagChecking = false;
localStorage.setItem(CLAN_TAG_KEY, "");
currentCheck = Promise.resolve();
emit();
return;
}
state.clanTagChecking = true;
emit();
const generation = clanCheckCounter;
const run = (): Promise<void> => {
if (generation !== clanCheckCounter) return Promise.resolve();
return runOwnershipCheck(tag, generation);
};
if (options.immediate) {
currentCheck = run();
} else {
const debounce = new Promise<void>((resolve) => {
resolveDebounce = resolve;
});
clanCheckTimer = setTimeout(() => {
clanCheckTimer = null;
const r = resolveDebounce;
resolveDebounce = null;
r?.();
}, CLAN_OWNERSHIP_DEBOUNCE_MS);
currentCheck = debounce.then(run);
}
}
async function runOwnershipCheck(tag: string, generation: number) {
const stillCurrent = () =>
generation === clanCheckCounter && state.clanTag.value === tag;
const me = await getUserMe();
if (!stillCurrent()) return;
const myTags = me
? (me.player.clans ?? []).map((c) => c.tag.toUpperCase())
: [];
if (!myTags.includes(tag.toUpperCase())) {
const exists = await fetchClanExists(tag);
if (!stillCurrent()) return;
if (exists !== false) {
rejectTag(tag);
return;
}
}
acceptTag(tag);
}
function acceptTag(tag: string) {
state.clanTag = { value: tag, valid: true, error: "" };
state.clanTagChecking = false;
localStorage.setItem(CLAN_TAG_KEY, tag);
emit();
}
function rejectTag(tag: string) {
state.clanTag = {
value: tag,
valid: false,
error: "username.tag_not_member",
};
state.clanTagChecking = false;
localStorage.removeItem(CLAN_TAG_KEY);
emit();
}
// Resolves once any in-flight async clan check settles. Returns the final
// ready state so callers can branch without a second read.
export async function awaitIdentityReady(): Promise<boolean> {
let last: Promise<void> | undefined;
while (currentCheck !== last) {
last = currentCheck;
await last;
}
return state.ready;
}
// Re-runs sync validation against the last raw input so error messages get
// re-translated when the active language changes. Does NOT re-trigger the
// async ownership check (the cached result is still correct).
export function revalidateIdentityTranslations() {
const trimmed = lastInput.username.trim();
const usernameResult = validateUsername(trimmed);
state.username = {
value: trimmed,
valid: usernameResult.isValid,
error: usernameResult.isValid ? "" : (usernameResult.error ?? ""),
};
const tag = sanitizeClanTag(lastInput.clanTag);
const tagResult = validateClanTag(tag);
// Preserve any existing ownership error (it's already an i18n key); only
// refresh the format-level error so language changes pick up new strings.
if (!tagResult.isValid) {
state.clanTag = {
value: tag,
valid: false,
error: tagResult.error ?? "",
};
}
emit();
}
let initialized = false;
export function initIdentityFromStorage() {
if (initialized) return;
initialized = true;
const storedUsername = localStorage.getItem(USERNAME_KEY) ?? "";
setUsername(storedUsername);
const storedClanTag = localStorage.getItem(CLAN_TAG_KEY) ?? "";
if (storedClanTag.length > 0) {
setClanTag(storedClanTag, { immediate: true });
} else {
setClanTag("", { immediate: true });
}
}
// Test-only reset; not part of the public surface but exported so unit tests
// can wipe singleton state between cases.
export function __resetIdentityStoreForTests() {
initialized = false;
listeners.clear();
state.username = { value: "", valid: false, error: "" };
state.clanTag = { value: "", valid: true, error: "" };
state.clanTagChecking = false;
state.ready = false;
lastInput = { username: "", clanTag: "" };
if (clanCheckTimer !== null) clearTimeout(clanCheckTimer);
clanCheckTimer = null;
clanCheckCounter = 0;
resolveDebounce = null;
currentCheck = Promise.resolve();
}
@@ -30,7 +30,13 @@ vi.mock("../../src/client/ClanApi", () => ({
import { getUserMe } from "../../src/client/Api";
import { fetchClanExists } from "../../src/client/ClanApi";
import { ClanTagInput } from "../../src/client/ClanTagInput";
import {
__resetIdentityStoreForTests,
awaitIdentityReady,
getClanTagForSubmit,
getIdentityState,
setClanTag,
} from "../../src/client/identity/IdentityStore";
const flushPromises = async () => {
for (let i = 0; i < 5; i++) {
@@ -43,6 +49,7 @@ beforeEach(() => {
vi.mocked(getUserMe).mockReset();
vi.mocked(fetchClanExists).mockReset();
vi.stubGlobal("localStorage", createMockLocalStorage());
__resetIdentityStoreForTests();
});
afterEach(() => {
@@ -51,7 +58,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("ClanTagInput async ownership check", () => {
describe("IdentityStore clan-tag ownership check", () => {
it("surfaces tag_not_member when user is not a member and clan exists", async () => {
vi.mocked(getUserMe).mockResolvedValue({
user: {},
@@ -66,16 +73,14 @@ describe("ClanTagInput async ownership check", () => {
} as any);
vi.mocked(fetchClanExists).mockResolvedValue(true);
const input = new ClanTagInput();
(input as any).clanTag = "ABC";
(input as any).validate();
setClanTag("ABC");
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(input.isValid()).toBe(false);
expect((input as any).ownershipError).toBe("username.tag_not_member");
const state = getIdentityState();
expect(state.clanTag.valid).toBe(false);
expect(state.clanTag.error).toBe("username.tag_not_member");
});
it("clears any stored clanTag when async detects ownership conflict", async () => {
@@ -93,10 +98,7 @@ describe("ClanTagInput async ownership check", () => {
vi.mocked(fetchClanExists).mockResolvedValue(true);
localStorage.setItem("clanTag", "ABC");
const input = new ClanTagInput();
(input as any).clanTag = "ABC";
(input as any).validate();
setClanTag("ABC");
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
@@ -108,15 +110,12 @@ describe("ClanTagInput async ownership check", () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(false);
const input = new ClanTagInput();
(input as any).clanTag = "FIC";
(input as any).validate();
setClanTag("FIC");
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(input.isValid()).toBe(true);
expect(getIdentityState().clanTag.valid).toBe(true);
expect(localStorage.getItem("clanTag")).toBe("FIC");
});
@@ -124,15 +123,12 @@ describe("ClanTagInput async ownership check", () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(null);
const input = new ClanTagInput();
(input as any).clanTag = "ABC";
(input as any).validate();
setClanTag("ABC");
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(input.isValid()).toBe(false);
expect(getIdentityState().clanTag.valid).toBe(false);
});
it("discards stale async results when the tag has changed", async () => {
@@ -146,16 +142,12 @@ describe("ClanTagInput async ownership check", () => {
.mockReturnValueOnce(first)
.mockReturnValueOnce(second);
const input = new ClanTagInput();
(input as any).clanTag = "AAA";
(input as any).validate();
setClanTag("AAA");
vi.advanceTimersByTime(401);
await flushPromises();
// Now the user switches to a different tag before the first response lands.
(input as any).clanTag = "BBB";
(input as any).validate();
// User switches to a different tag before the first response lands.
setClanTag("BBB");
vi.advanceTimersByTime(401);
await flushPromises();
@@ -163,41 +155,53 @@ describe("ClanTagInput async ownership check", () => {
// tag is no longer AAA, so this must NOT clobber the result for BBB.
resolveFirst(true);
await flushPromises();
expect((input as any).ownershipError).toBe("");
expect(getIdentityState().clanTag.error).toBe("");
// Second response says BBB doesn't exist → fictional, accept.
resolveSecond(false);
await flushPromises();
await flushPromises();
expect(input.isValid()).toBe(true);
expect(getIdentityState().clanTag.valid).toBe(true);
expect(localStorage.getItem("clanTag")).toBe("BBB");
});
it("clears the pending timer in disconnectedCallback", () => {
it("flips ready false while a check is in flight, true on success", async () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(false);
const input = new ClanTagInput();
(input as any).clanTag = "ABC";
(input as any).validate();
setClanTag("ABC");
// Pre-debounce: checking flag already set so play buttons disable
// immediately, not after the debounce.
expect(getIdentityState().clanTagChecking).toBe(true);
expect(getIdentityState().ready).toBe(false);
expect((input as any).checkTimer).not.toBeNull();
(input as any).disconnectedCallback();
expect((input as any).checkTimer).toBeNull();
vi.advanceTimersByTime(401);
const ready = await awaitIdentityReady();
expect(ready).toBe(false); // username still invalid (empty)
expect(getIdentityState().clanTagChecking).toBe(false);
expect(getIdentityState().clanTag.valid).toBe(true);
});
it("getValue returns null for empty/short/invalid tags and the tag when valid", () => {
const input = new ClanTagInput();
(input as any).clanTag = "";
expect(input.getValue()).toBeNull();
(input as any).clanTag = "A";
expect(input.getValue()).toBeNull();
(input as any).clanTag = "TOOLONG";
expect(input.getValue()).toBeNull();
(input as any).clanTag = "ABC";
expect(input.getValue()).toBe("ABC");
it("getClanTagForSubmit returns null while empty/short/pending; tag once accepted", async () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(false);
setClanTag("");
expect(getClanTagForSubmit()).toBeNull();
setClanTag("A");
expect(getClanTagForSubmit()).toBeNull();
setClanTag("ABC");
// Ownership check hasn't resolved yet → not submittable.
expect(getIdentityState().clanTagChecking).toBe(true);
expect(getClanTagForSubmit()).toBeNull();
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(getClanTagForSubmit()).toBe("ABC");
});
});