Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@
</div>
</div>

<!-- App-level overlays (shown on the menu and in-game) -->
<session-expired-modal></session-expired-modal>

<!-- Game modals and overlays -->
<emoji-table></emoji-table>
<build-menu></build-menu>
Expand Down
7 changes: 7 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@
"unfavorite": "Remove from favourites"
},
"matchmaking_button": {
"connection_issue": "Couldn't verify your login. Please try again.",
"description": "(ALPHA)",
"login_required": "Login to play ranked!",
"must_login": "You must be logged in to play ranked matchmaking.",
Expand Down Expand Up @@ -1221,6 +1222,12 @@
"slider_tooltip": "{percent}% • {amount}",
"title_with_name": "Send Troops to {name}"
},
"session_expired": {
"body": "Your session has expired. Please log in again to continue.",
"dismiss": "Dismiss",
"log_in": "Log in",
"title": "Signed out"
},
"single_modal": {
"bots": "Tribes: ",
"bots_disabled": "Disabled",
Expand Down
2 changes: 1 addition & 1 deletion src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ export class AccountModal extends BaseModal {
}

private async handleLogout() {
await logOut();
await logOut({ userInitiated: true });
this.close();
// Refresh the page after logout to update the UI state
window.location.reload();
Expand Down
19 changes: 15 additions & 4 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export async function fetchPlayerById(
}
}

// Remembers whether the last successful /users/@me showed a linked account.
// Used to decide whether a session-expiry should prompt re-login (linked users)
// or be handled silently (guests). Survives a later auth failure on purpose —
// it records what the user *was*, which is exactly what we need at that point.
let __lastKnownLinked = false;
export function wasLinkedAccount(): boolean {
return __lastKnownLinked;
}

let __userMe: Promise<UserMeResponse | false> | null = null;
export async function getUserMe(): Promise<UserMeResponse | false> {
if (__userMe !== null) {
Expand All @@ -70,10 +79,11 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
authorization: `Bearer ${jwt}`,
},
});
if (response.status === 401) {
await logOut();
return false;
}
// A 401 here is treated like any other non-200 (return false). We
// deliberately do NOT logOut(): the JWT was just refreshed by userAuth(),
// so a 401 from /users/@me is transient/ambiguous and must not revoke the
// session or wipe the persistent identity. The backend already made
// /users/@me 401 non-authoritative.
if (response.status !== 200) return false;
Comment on lines +82 to 87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not cache transient false user lookups.

This path now treats 401/non-200 as transient, but __userMe still stores the promise that resolves to false. Later getUserMe() calls can keep returning the cached false and never retry until something calls invalidateUserMe().

Proposed fix
-  __userMe = (async () => {
+  const request = (async () => {
     try {
       const userAuthResult = await userAuth();
       if (!userAuthResult) return false;
@@
       return false;
     }
   })();
-  return __userMe;
+  __userMe = request;
+  const result = await request;
+  if (result === false && __userMe === request) {
+    __userMe = null;
+  }
+  return result;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// A 401 here is treated like any other non-200 (return false). We
// deliberately do NOT logOut(): the JWT was just refreshed by userAuth(),
// so a 401 from /users/@me is transient/ambiguous and must not revoke the
// session or wipe the persistent identity. The backend already made
// /users/@me 401 non-authoritative.
if (response.status !== 200) return false;
const request = (async () => {
try {
const userAuthResult = await userAuth();
if (!userAuthResult) return false;
// ...
} catch {
return false;
}
})();
__userMe = request;
const result = await request;
if (result === false && __userMe === request) {
__userMe = null;
}
return result;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/Api.ts` around lines 73 - 78, The transient non-200 path in
getUserMe() is still caching a promise that resolves to false in __userMe, which
can cause later calls to keep returning the stale false result instead of
retrying. Update the getUserMe/userAuth flow so only successful user lookups are
cached, and ensure false/unauthorized results are not stored in __userMe (or are
cleared immediately) while preserving the existing invalidateUserMe() behavior.

const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
Expand All @@ -82,6 +92,7 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
console.error("Invalid response", error);
return false;
}
__lastKnownLinked = hasLinkedAccount(result.data);
return result.data;
} catch (e) {
return false;
Expand Down
130 changes: 106 additions & 24 deletions src/client/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,22 @@ export async function getAuthHeader(): Promise<string> {
return `Bearer ${jwt}`;
}

export async function logOut(allSessions: boolean = false): Promise<boolean> {
export interface LogOutOptions {
/** Revoke every session (/auth/revoke) instead of just the current one. */
allSessions?: boolean;
/**
* Set only for an explicit, user-initiated logout — the one case where we
* also wipe the local persistent identity + cosmetics. Error-path callers
* must leave this false, or a transient failure becomes a permanent new
* guest account.
*/
userInitiated?: boolean;
}

export async function logOut({
allSessions = false,
userInitiated = false,
}: LogOutOptions = {}): Promise<boolean> {
try {
const response = await fetch(
getApiBase() + (allSessions ? "/auth/revoke" : "/auth/logout"),
Expand All @@ -98,9 +113,14 @@ export async function logOut(allSessions: boolean = false): Promise<boolean> {
return false;
} finally {
__jwt = null;
localStorage.removeItem(PERSISTENT_ID_KEY);
new UserSettings().clearFlag();
new UserSettings().setSelectedPatternName(undefined);
// Only destroy the local persistent identity / cosmetics on an explicit
// user logout. Error-path callers must NOT wipe identity, or a transient
// failure turns into a permanent brand-new guest account.
if (userInitiated) {
localStorage.removeItem(PERSISTENT_ID_KEY);
new UserSettings().clearFlag();
new UserSettings().setSelectedPatternName(undefined);
}
}
}

Expand Down Expand Up @@ -188,29 +208,91 @@ async function refreshJwt(): Promise<void> {
}
}

// Outcome of the most recent refresh attempt, so callers (e.g. ranked
// matchmaking) can tell "your session expired" apart from "we couldn't reach
// the auth server right now".
export type RefreshOutcome = "ok" | "expired" | "transient";
let __lastRefreshOutcome: RefreshOutcome = "ok";

export function getLastRefreshOutcome(): RefreshOutcome {
return __lastRefreshOutcome;
}

const REFRESH_MAX_ATTEMPTS = 3;

function refreshBackoffMs(attempt: number): number {
// Backoff between attempts: 500ms, then 1000ms.
return 500 * 2 ** (attempt - 1);
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function notifySessionExpired(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent("auth-session-expired"));
}

async function doRefreshJwt(): Promise<void> {
try {
console.log("Refreshing jwt");
const response = await fetch(getApiBase() + "/auth/refresh", {
method: "POST",
credentials: "include",
});
if (response.status !== 200) {
console.error("Refresh failed", response);
logOut();
return;
// Whether an authenticated session was active before this attempt. Only a
// session that dies mid-use should surface the "signed out" UI.
const hadSession = __jwt !== null;

for (let attempt = 1; attempt <= REFRESH_MAX_ATTEMPTS; attempt++) {
try {
console.log(
`Refreshing jwt (attempt ${attempt}/${REFRESH_MAX_ATTEMPTS})`,
);
const response = await fetch(getApiBase() + "/auth/refresh", {
method: "POST",
credentials: "include",
});

if (response.status === 200) {
const json = await response.json();
const { jwt, expiresIn } = json;
__expiresAt = Date.now() + expiresIn * 1000;
__jwt = jwt;
__lastRefreshOutcome = "ok";
console.log("Refresh succeeded");
return;
}

// 401/403 are definitive: the refresh token is genuinely invalid or
// expired, so retrying can't help. Do a "soft" logout — clear the
// in-memory JWT but preserve the session cookie + persistent identity —
// and let a previously-signed-in user be prompted to log in again.
if (response.status === 401 || response.status === 403) {
console.error("Refresh rejected — session expired", response.status);
__jwt = null;
__lastRefreshOutcome = "expired";
if (hadSession) notifySessionExpired();
return;
Comment on lines +266 to +271

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear cached user data when the session expires.

This branch clears __jwt, but getUserMe() can still return the cached successful /users/@me promise from src/client/Api.ts lines 65-68. After auth-session-expired, downstream code like matchmaking can still see the stale user as logged in.

Proposed fix
 export function invalidateUserMe() {
   __userMe = null;
 }
+
+if (typeof window !== "undefined") {
+  window.addEventListener("auth-session-expired", invalidateUserMe);
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/Auth.ts` around lines 254 - 259, The session-expired branch in
Auth.ts only clears __jwt, so the cached /users/@me result in getUserMe() can
still be reused after auth-session-expired. Update the expiration handling in
the refresh/rejected path to also invalidate the cached user promise/state
maintained in Api.ts (the getUserMe cache), so downstream callers stop seeing a
stale logged-in user after NotifySessionExpired() runs.

}

// Everything else (5xx, 429, ...) is transient — fall through to retry.
console.error(
`Refresh failed (status ${response.status}), attempt ${attempt}/${REFRESH_MAX_ATTEMPTS}`,
);
} catch (e) {
// Network error / server unreachable — transient, fall through to retry.
console.error(
`Refresh failed (network), attempt ${attempt}/${REFRESH_MAX_ATTEMPTS}`,
e,
);
}

if (attempt < REFRESH_MAX_ATTEMPTS) {
await delay(refreshBackoffMs(attempt));
}
const json = await response.json();
const { jwt, expiresIn } = json;
__expiresAt = Date.now() + expiresIn * 1000;
console.log("Refresh succeeded");
__jwt = jwt;
} catch (e) {
console.error("Refresh failed", e);
// if server unreachable, just clear jwt
__jwt = null;
return;
}

// Transient failures exhausted. Clear the in-memory JWT only — keep the
// session cookie and persistent identity so the next refresh can recover.
console.error("Refresh failed after retries; staying recoverable");
__jwt = null;
__lastRefreshOutcome = "transient";
}

export async function sendMagicLink(email: string): Promise<boolean> {
Expand Down
1 change: 1 addition & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { modalRouter } from "./ModalRouter";
import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PatternInput";
import "./SessionExpiredModal";
import "./SinglePlayerModal";
import { StoreModal } from "./Store";
import "./TerritoryPatternsModal";
Expand Down
17 changes: 16 additions & 1 deletion src/client/Matchmaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { UserMeResponse } from "../core/ApiSchemas";
import { getUserMe, hasLinkedAccount } from "./Api";
import { getPlayToken } from "./Auth";
import { getLastRefreshOutcome, getPlayToken } from "./Auth";
import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import { modalHeader } from "./components/ui/ModalHeader";
Expand Down Expand Up @@ -126,6 +126,21 @@ export class MatchmakingModal extends BaseModal {
userMe.user.google !== undefined ||
userMe.user.email !== undefined);
if (!isLoggedIn) {
// A transient auth-server hiccup (rather than a genuine "not logged in")
// shouldn't bounce the player to the login page — ask them to retry.
if (getLastRefreshOutcome() === "transient") {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("matchmaking_button.connection_issue"),
color: "red",
duration: 3000,
},
}),
);
this.close();
return;
}
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
Expand Down
66 changes: 66 additions & 0 deletions src/client/SessionExpiredModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { wasLinkedAccount } from "./Api";
import { BaseModal, ModalConfig } from "./components/BaseModal";
import { translateText } from "./Utils";

/**
* App-level warning shown when a previously-signed-in user's auth session
* expires (a definitive 401/403 on /auth/refresh). Auth.ts dispatches the
* "auth-session-expired" window event; we only surface it for users who were
* actually logged in to an account — guests get a fresh session silently.
*/
@customElement("session-expired-modal")
export class SessionExpiredModal extends BaseModal {
connectedCallback(): void {
super.connectedCallback();
window.addEventListener("auth-session-expired", this.onSessionExpired);
}

disconnectedCallback(): void {
window.removeEventListener("auth-session-expired", this.onSessionExpired);
super.disconnectedCallback();
}

private onSessionExpired = (): void => {
if (!wasLinkedAccount()) return;
if (this.isOpen()) return;
this.open();
};

protected modalConfig(): ModalConfig {
return {
title: translateText("session_expired.title"),
hideHeader: false,
hideCloseButton: false,
maxWidth: "420px",
};
}

private logIn(): void {
this.close();
window.showPage?.("page-account");
}

protected renderBody() {
return html`
<div class="px-6 py-4 text-gray-800 dark:text-gray-200">
<p class="mb-6">${translateText("session_expired.body")}</p>
<div class="flex justify-end gap-3">
<button
class="px-4 py-2 rounded-md bg-gray-200 text-gray-900 dark:bg-gray-700 dark:text-gray-100"
@click=${() => this.close()}
>
${translateText("session_expired.dismiss")}
</button>
<button
class="px-4 py-2 rounded-md bg-blue-600 text-white"
@click=${() => this.logIn()}
>
${translateText("session_expired.log_in")}
</button>
</div>
</div>
`;
}
}
Loading
Loading