diff --git a/resources/lang/en.json b/resources/lang/en.json index ec27601e8..92fe6a940 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -157,10 +157,15 @@ }, "account_modal": { "title": "Account", - "logged_in_as": "Logged in as {email}", + "logged_in_as": "Logged in as {account_name}", "fetching_account": "Fetching account information...", "logged_in_with_discord": "Logged in with Discord", - "recovery_email_sent": "Recovery email sent to {email}" + "recovery_email_sent": "Recovery email sent to {email}", + "player_id": "Player ID: {id}", + "not_found": "Not Found", + "clear_session": "Clear Session", + "failed_to_send_recovery_email": "Failed to send recovery email", + "enter_email_address": "Please enter an email address" }, "stats_modal": { "title": "Stats", @@ -704,6 +709,7 @@ "colors": "Colors", "purchase": "Purchase", "show_only_owned": "My Skins", + "not_logged_in": "Not logged in", "blocked": { "login": "You must be logged in to access this skin.", "purchase": "Purchase this skin to unlock it." diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index e20a9613e..6c9f6ba48 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -5,19 +5,14 @@ import { PlayerStatsTree, UserMeResponse, } from "../core/ApiSchemas"; +import { fetchPlayerById, getUserMe } from "./Api"; +import { discordLogin, logOut, sendMagicLink } from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; import "./components/baseComponents/stats/GameList"; import "./components/baseComponents/stats/PlayerStatsTable"; import "./components/baseComponents/stats/PlayerStatsTree"; import "./components/Difficulties"; import "./components/PatternButton"; -import { - discordLogin, - fetchPlayerById, - getApiBase, - getUserMe, - logOut, -} from "./jwt"; import { isInIframe, translateText } from "./Utils"; @customElement("account-modal") @@ -30,10 +25,7 @@ export class AccountModal extends LitElement { @state() private email: string = ""; @state() private isLoadingUser: boolean = false; - private loggedInEmail: string | null = null; - private loggedInDiscord: string | null = null; private userMeResponse: UserMeResponse | null = null; - private playerId: string | null = null; private statsTree: PlayerStatsTree | null = null; private recentGames: PlayerGame[] = []; @@ -44,8 +36,7 @@ export class AccountModal extends LitElement { const customEvent = event as CustomEvent; if (customEvent.detail) { this.userMeResponse = customEvent.detail as UserMeResponse; - this.playerId = this.userMeResponse?.player?.publicId; - if (this.playerId === undefined) { + if (this.userMeResponse?.player?.publicId === undefined) { this.statsTree = null; this.recentGames = []; } @@ -67,31 +58,90 @@ export class AccountModal extends LitElement { id="account-modal" title="${translateText("account_modal.title") || "Account"}" > - ${this.renderInner()} + ${this.isLoadingUser + ? html` +
+

+ ${translateText("account_modal.fetching_account")} +

+
+
+ ` + : this.renderInner()} `; } private renderInner() { - if (this.isLoadingUser) { - return html` -
-

${translateText("account_modal.fetching_account")}

-
-
- `; - } - if (this.loggedInDiscord) { - return this.renderLoggedInDiscord(); - } else if (this.loggedInEmail) { - return this.renderLoggedInEmail(); + if (this.userMeResponse?.user) { + return this.renderAccountInfo(); } else { return this.renderLoginOptions(); } } + private renderAccountInfo() { + return html` +
+
+

+ ${translateText("account_modal.player_id", { + id: + this.userMeResponse?.player?.publicId ?? + translateText("account_modal.not_found"), + })} +

+
+
+

${this.renderLoggedInAs()}

+
+
+ +
+ ${this.renderPlayerStats()} +
+ `; + } + + private renderLoggedInAs(): TemplateResult { + const me = this.userMeResponse?.user; + if (me?.discord) { + return html`

+ ${translateText("account_modal.logged_in_as", { + account_name: me.discord.global_name ?? "", + })} +

+ ${this.renderLogoutButton()}`; + } else if (me?.email) { + return html`

+ ${translateText("account_modal.logged_in_as", { + account_name: me.email, + })} +

+ ${this.renderLogoutButton()}`; + } + return this.renderLoginOptions(); + } + + private renderPlayerStats(): TemplateResult { + return html` + +
+ this.viewGame(id)} + > + `; + } + private viewGame(gameId: string): void { this.close(); const path = location.pathname; @@ -103,46 +153,7 @@ export class AccountModal extends LitElement { window.dispatchEvent(new HashChangeEvent("hashchange")); } - private renderLoggedInDiscord() { - return html` -
-
-

- Logged in with Discord as ${this.loggedInDiscord} -

- ${this.logoutButton()} -
-
- - -
- this.viewGame(id)} - > -
-
- `; - } - - private renderLoggedInEmail(): TemplateResult { - return html` -
-
-

- Logged in as ${this.loggedInEmail} -

-
- ${this.logoutButton()} -
- `; - } - - private logoutButton(): TemplateResult { + private renderLogoutButton(): TemplateResult { return html` `; } @@ -235,41 +247,19 @@ export class AccountModal extends LitElement { private async handleSubmit() { if (!this.email) { - alert("Please enter an email address"); + alert(translateText("account_modal.enter_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, + const success = await sendMagicLink(this.email); + if (success) { + alert( + translateText("account_modal.recovery_email_sent", { 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."); + ); + } else { + alert(translateText("account_modal.failed_to_send_recovery_email")); } } @@ -284,14 +274,10 @@ export class AccountModal extends LitElement { void getUserMe() .then((userMe) => { if (userMe) { - this.loggedInEmail = userMe.user.email ?? null; - this.loggedInDiscord = userMe.user.discord?.global_name ?? null; - if (this.playerId) { - this.loadFromApi(this.playerId); + this.userMeResponse = userMe; + if (this.userMeResponse?.player?.publicId) { + this.loadPlayerProfile(this.userMeResponse.player.publicId); } - } else { - this.loggedInEmail = null; - this.loggedInDiscord = null; } this.isLoadingUser = false; this.requestUpdate(); @@ -315,9 +301,9 @@ export class AccountModal extends LitElement { window.location.reload(); } - private async loadFromApi(playerId: string): Promise { + private async loadPlayerProfile(publicId: string): Promise { try { - const data = await fetchPlayerById(playerId); + const data = await fetchPlayerById(publicId); if (!data) { this.requestUpdate(); return; diff --git a/src/client/Api.ts b/src/client/Api.ts new file mode 100644 index 000000000..09bfa3d85 --- /dev/null +++ b/src/client/Api.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; +import { + PlayerProfile, + PlayerProfileSchema, + UserMeResponse, + UserMeResponseSchema, +} from "../core/ApiSchemas"; +import { getAuthHeader, logOut, userAuth } from "./Auth"; + +export async function fetchPlayerById( + playerId: string, +): Promise { + try { + const userAuthResult = await userAuth(); + if (!userAuthResult) return false; + const { jwt } = userAuthResult; + + const url = `${getApiBase()}/player/${encodeURIComponent(playerId)}`; + + const res = await fetch(url, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${jwt}`, + }, + }); + + if (res.status !== 200) { + console.warn( + "fetchPlayerById: unexpected status", + res.status, + res.statusText, + ); + return false; + } + + const json = await res.json(); + const parsed = PlayerProfileSchema.safeParse(json); + if (!parsed.success) { + console.warn("fetchPlayerById: Zod validation failed", parsed.error); + return false; + } + + return parsed.data; + } catch (err) { + console.warn("fetchPlayerById: request failed", err); + return false; + } +} +export async function getUserMe(): Promise { + try { + const userAuthResult = await userAuth(); + if (!userAuthResult) return false; + const { jwt } = userAuthResult; + + // Get the user object + const response = await fetch(getApiBase() + "/users/@me", { + headers: { + authorization: `Bearer ${jwt}`, + }, + }); + if (response.status === 401) { + await logOut(); + return false; + } + if (response.status !== 200) return false; + const body = await response.json(); + const result = UserMeResponseSchema.safeParse(body); + if (!result.success) { + const error = z.prettifyError(result.error); + console.error("Invalid response", error); + return false; + } + return result.data; + } catch (e) { + return false; + } +} + +export async function createCheckoutSession( + priceId: string, + colorPaletteName: string | null, +): Promise { + try { + const response = await fetch( + `${getApiBase()}/stripe/create-checkout-session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ + priceId: priceId, + hostname: window.location.origin, + colorPaletteName: colorPaletteName, + }), + }, + ); + if (!response.ok) { + console.error( + "createCheckoutSession: request failed", + response.status, + response.statusText, + ); + return false; + } + const json = await response.json(); + return json.url; + } catch (e) { + console.error("createCheckoutSession: request failed", e); + return false; + } +} + +export function getApiBase() { + const domainname = getAudience(); + + if (domainname === "localhost") { + const apiDomain = process?.env?.API_DOMAIN; + if (apiDomain) { + return `https://${apiDomain}`; + } + return localStorage.getItem("apiHost") ?? "http://localhost:8787"; + } + + return `https://api.${domainname}`; +} + +export function getAudience() { + const { hostname } = new URL(window.location.href); + const domainname = hostname.split(".").slice(-2).join("."); + return domainname; +} diff --git a/src/client/Auth.ts b/src/client/Auth.ts new file mode 100644 index 000000000..fbc46f121 --- /dev/null +++ b/src/client/Auth.ts @@ -0,0 +1,222 @@ +import { decodeJwt } from "jose"; +import { z } from "zod"; +import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; +import { base64urlToUuid } from "../core/Base64"; +import { getApiBase, getAudience } from "./Api"; +import { generateCryptoRandomUUID } from "./Utils"; + +export type UserAuth = { jwt: string; claims: TokenPayload } | false; + +const PERSISTENT_ID_KEY = "player_persistent_id"; + +let __jwt: string | null = null; + +export function discordLogin() { + const redirectUri = encodeURIComponent(window.location.href); + window.location.href = `${getApiBase()}/auth/login/discord?redirect_uri=${redirectUri}`; +} + +export async function tempTokenLogin(token: string): Promise { + const response = await fetch( + `${getApiBase()}/auth/login/token?login-token=${token}`, + { + credentials: "include", + }, + ); + if (response.status !== 200) { + console.error("Token login failed", response); + return null; + } + const json = await response.json(); + const { email } = json; + return email; +} + +export async function getAuthHeader(): Promise { + const userAuthResult = await userAuth(); + if (!userAuthResult) return ""; + const { jwt } = userAuthResult; + return `Bearer ${jwt}`; +} + +export async function logOut(allSessions: boolean = false): Promise { + try { + const response = await fetch( + getApiBase() + (allSessions ? "/auth/revoke" : "/auth/logout"), + { + method: "POST", + credentials: "include", + }, + ); + + if (response.ok === false) { + console.error("Logout failed", response); + return false; + } + + return true; + } catch (e) { + console.error("Logout failed", e); + return false; + } finally { + __jwt = null; + localStorage.removeItem(PERSISTENT_ID_KEY); + } +} + +export async function isLoggedIn(): Promise { + const userAuthResult = await userAuth(); + return userAuthResult !== false; +} + +export async function userAuth( + shouldRefresh: boolean = true, +): Promise { + try { + const jwt = __jwt; + if (!jwt) { + if (!shouldRefresh) { + console.warn("No JWT found and shouldRefresh is false"); + return false; + } + console.log("No JWT found"); + await refreshJwt(); + return userAuth(false); + } + + // Verify the JWT (requires browser support) + // const jwks = createRemoteJWKSet( + // new URL(getApiBase() + "/.well-known/jwks.json"), + // ); + // const { payload, protectedHeader } = await jwtVerify(token, jwks, { + // issuer: getApiBase(), + // audience: getAudience(), + // }); + + const payload = decodeJwt(jwt); + const { iss, aud, exp } = payload; + + if (iss !== getApiBase()) { + // JWT was not issued by the correct server + console.error('unexpected "iss" claim value'); + logOut(); + return false; + } + const myAud = getAudience(); + if (myAud !== "localhost" && aud !== myAud) { + // JWT was not issued for this website + console.error('unexpected "aud" claim value'); + logOut(); + return false; + } + const now = Math.floor(Date.now() / 1000); + if (exp !== undefined && now >= exp - 3 * 60) { + console.log("jwt expired or about to expire"); + if (!shouldRefresh) { + console.error("jwt expired and shouldRefresh is false"); + return false; + } + await refreshJwt(); + + // Try to get login info again after refreshing + return userAuth(false); + } + + const result = TokenPayloadSchema.safeParse(payload); + if (!result.success) { + const error = z.prettifyError(result.error); + console.error("Invalid payload", error); + return false; + } + + const claims = result.data; + return { jwt, claims }; + } catch (e) { + console.error("isLoggedIn failed", e); + return false; + } +} + +async function refreshJwt(): Promise { + 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; + } + const json = await response.json(); + const { jwt } = json; + console.log("Refresh succeeded"); + __jwt = jwt; + } catch (e) { + console.error("Refresh failed", e); + logOut(); + return; + } +} + +export async function sendMagicLink(email: string): Promise { + try { + const apiBase = getApiBase(); + const response = await fetch(`${apiBase}/auth/magic-link`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + redirectDomain: window.location.origin, + email: email, + }), + }); + + if (response.ok) { + return true; + } else { + console.error( + "Failed to send recovery email:", + response.status, + response.statusText, + ); + return false; + } + } catch (error) { + console.error("Error sending recovery email:", error); + return false; + } +} + +// WARNING: DO NOT EXPOSE THIS ID +export async function getPlayToken(): Promise { + const result = await userAuth(); + if (result !== false) return result.jwt; + return getPersistentIDFromLocalStorage(); +} + +// WARNING: DO NOT EXPOSE THIS ID +export function getPersistentID(): string { + const jwt = __jwt; + if (!jwt) return getPersistentIDFromLocalStorage(); + const payload = decodeJwt(jwt); + const sub = payload.sub; + if (!sub) return getPersistentIDFromLocalStorage(); + return base64urlToUuid(sub); +} + +// WARNING: DO NOT EXPOSE THIS ID +function getPersistentIDFromLocalStorage(): string { + // Try to get existing localStorage + const value = localStorage.getItem(PERSISTENT_ID_KEY); + if (value) return value; + + // If no localStorage exists, create new ID and set localStorage + const newID = generateCryptoRandomUUID(); + localStorage.setItem(PERSISTENT_ID_KEY, newID); + + return newID; +} diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index bc7a2a31d..2bacd56e2 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -26,6 +26,7 @@ import { GameView, PlayerView } from "../core/game/GameView"; import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader"; import { UserSettings } from "../core/game/UserSettings"; import { WorkerClient } from "../core/worker/WorkerClient"; +import { getPersistentID } from "./Auth"; import { AutoUpgradeEvent, DoBoatAttackEvent, @@ -36,7 +37,6 @@ import { TickMetricsEvent, } from "./InputHandler"; import { endGame, startGame, startTime } from "./LocalPersistantStats"; -import { getPersistentID } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { SendAttackIntentEvent, @@ -228,7 +228,7 @@ export class ClientGameRunner { this.lastMessageTime = Date.now(); } - private saveGame(update: WinUpdate) { + private async saveGame(update: WinUpdate) { if (this.myPlayer === null) { return; } diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 8eeb1f74d..821d8e25e 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -5,8 +5,7 @@ import { CosmeticsSchema, Pattern, } from "../core/CosmeticSchemas"; -import { getApiBase, getAuthHeader } from "./jwt"; -import { getPersistentID } from "./Main"; +import { createCheckoutSession, getApiBase } from "./Api"; export async function handlePurchase( pattern: Pattern, @@ -17,37 +16,15 @@ export async function handlePurchase( return; } - const response = await fetch( - `${getApiBase()}/stripe/create-checkout-session`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - authorization: getAuthHeader(), - "X-Persistent-Id": getPersistentID(), - }, - body: JSON.stringify({ - priceId: pattern.product.priceId, - hostname: window.location.origin, - colorPaletteName: colorPalette?.name, - }), - }, + const url = await createCheckoutSession( + pattern.product.priceId, + colorPalette?.name ?? null, ); - - if (!response.ok) { - console.error( - `Error purchasing pattern:${response.status} ${response.statusText}`, - ); - if (response.status === 401) { - alert("You are not logged in. Please log in to purchase a pattern."); - } else { - alert("Something went wrong. Please try again later."); - } + if (url === false) { + alert("Failed to create checkout session."); return; } - const { url } = await response.json(); - // Redirect to Stripe checkout window.location.href = url; } diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 2c89e9804..300ea08a9 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -4,10 +4,10 @@ import { translateText } from "../client/Utils"; import { GameInfo, GameRecordSchema } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; -import { getApiBase } from "./jwt"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index d813c52a3..cba428739 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -17,9 +17,9 @@ import { getClanTag, replacer, } from "../core/Util"; +import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; -import { getPersistentID } from "./Main"; import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; export class LocalServer { diff --git a/src/client/Main.ts b/src/client/Main.ts index ba12fe136..c4d0ecc32 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -7,6 +7,8 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; +import { getUserMe } from "./Api"; +import { getPlayToken, userAuth } from "./Auth"; import { joinLobby } from "./ClientGameRunner"; import { fetchCosmetics } from "./Cosmetics"; import "./DarkModeButton"; @@ -36,14 +38,9 @@ import { SendKickPlayerIntentEvent } from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; import { UsernameInput } from "./UsernameInput"; -import { - generateCryptoRandomUUID, - incrementGamesPlayed, - isInIframe, -} from "./Utils"; +import { incrementGamesPlayed, isInIframe } from "./Utils"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; -import { getUserMe, isLoggedIn } from "./jwt"; import "./styles.css"; declare global { @@ -115,7 +112,7 @@ class Client { constructor() {} - initialize(): void { + async initialize(): Promise { // Prefetch turnstile token so it is available when // the user joins a lobby. this.turnstileTokenPromise = getTurnstileToken(); @@ -284,7 +281,7 @@ class Client { } }; - if (isLoggedIn() === false) { + if ((await userAuth()) === false) { // Not logged in onUserMe(false); } else { @@ -498,7 +495,7 @@ class Client { }, turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getCurrentUsername() ?? "", - token: getPlayToken(), + token: await getPlayToken(), clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, @@ -650,46 +647,6 @@ document.addEventListener("DOMContentLoaded", () => { new Client().initialize(); }); -// WARNING: DO NOT EXPOSE THIS ID -export function getPlayToken(): string { - const result = isLoggedIn(); - if (result !== false) return result.token; - return getPersistentIDFromCookie(); -} - -// WARNING: DO NOT EXPOSE THIS ID -export function getPersistentID(): string { - const result = isLoggedIn(); - if (result !== false) return result.claims.sub; - return getPersistentIDFromCookie(); -} - -// WARNING: DO NOT EXPOSE THIS ID -function getPersistentIDFromCookie(): string { - const COOKIE_NAME = "player_persistent_id"; - - // Try to get existing cookie - const cookies = document.cookie.split(";"); - for (const cookie of cookies) { - const [cookieName, cookieValue] = cookie.split("=").map((c) => c.trim()); - if (cookieName === COOKIE_NAME) { - return cookieValue; - } - } - - // If no cookie exists, create new ID and set cookie - const newID = generateCryptoRandomUUID(); - document.cookie = [ - `${COOKIE_NAME}=${newID}`, - `max-age=${5 * 365 * 24 * 60 * 60}`, // 5 years - "path=/", - "SameSite=Strict", - "Secure", - ].join(";"); - - return newID; -} - async function getTurnstileToken(): Promise<{ token: string; createdAt: number; diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index e9118f790..1e1a2b405 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -2,9 +2,10 @@ import { html, LitElement } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { generateID } from "../core/Util"; +import { getPlayToken } from "./Auth"; import "./components/Difficulties"; import "./components/PatternButton"; -import { getPlayToken, JoinLobbyEvent } from "./Main"; +import { JoinLobbyEvent } from "./Main"; import { translateText } from "./Utils"; @customElement("matchmaking-modal") @@ -53,7 +54,7 @@ export class MatchmakingModal extends LitElement { const config = await getServerConfigFromClient(); this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`); - this.socket.onopen = () => { + this.socket.onopen = async () => { console.log("Connected to matchmaking server"); setTimeout(() => { // Set a delay so the user can see the "connecting" message, @@ -64,7 +65,7 @@ export class MatchmakingModal extends LitElement { this.socket?.send( JSON.stringify({ type: "auth", - playToken: getPlayToken(), + playToken: await getPlayToken(), }), ); }; diff --git a/src/client/StatsModal.ts b/src/client/StatsModal.ts index b70607fce..0428f72d2 100644 --- a/src/client/StatsModal.ts +++ b/src/client/StatsModal.ts @@ -4,7 +4,7 @@ import { ClanLeaderboardResponse, ClanLeaderboardResponseSchema, } from "../core/ApiSchemas"; -import { getApiBase } from "./jwt"; +import { getApiBase } from "./Api"; import { translateText } from "./Utils"; @customElement("stats-modal") diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 79f4666f6..f9e27e238 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -134,17 +134,9 @@ export class TerritoryPatternsModal extends LitElement { return html`
- + ${this.isLoggedIn() + ? this.renderMySkinsButton() + : this.renderNotLoggedInWarning()}
{ + this.showOnlyOwned = !this.showOnlyOwned; + }} + > + ${translateText("territory_patterns.show_only_owned")} + `; + } + + private renderNotLoggedInWarning(): TemplateResult { + return html``; + } + private renderColorSwatchGrid(): TemplateResult { const hexCodes = ( this.userMeResponse === false @@ -276,4 +290,14 @@ export class TerritoryPatternsModal extends LitElement { render(preview, this.previewButton); this.requestUpdate(); } + + private isLoggedIn(): boolean { + if (this.userMeResponse === false) { + return false; + } + return ( + this.userMeResponse.user.discord !== undefined || + this.userMeResponse.user.email !== undefined + ); + } } diff --git a/src/client/TokenLoginModal.ts b/src/client/TokenLoginModal.ts index 4aba112fb..cb7ef143e 100644 --- a/src/client/TokenLoginModal.ts +++ b/src/client/TokenLoginModal.ts @@ -1,8 +1,8 @@ import { html, LitElement } from "lit"; import { customElement, query } from "lit/decorators.js"; +import { tempTokenLogin } from "./Auth"; import "./components/Difficulties"; import "./components/PatternButton"; -import { tokenLogin } from "./jwt"; import { translateText } from "./Utils"; @customElement("token-login") @@ -79,7 +79,7 @@ export class TokenLoginModal extends LitElement { return; } try { - this.email = await tokenLogin(this.token); + this.email = await tempTokenLogin(this.token); if (!this.email) { return; } diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 14a94d8fd..8de7b4abf 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -10,13 +10,13 @@ import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas"; import { EventBus } from "../../../core/EventBus"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; +import { getUserMe } from "../../Api"; import "../../components/PatternButton"; import { fetchCosmetics, handlePurchase, patternRelationship, } from "../../Cosmetics"; -import { getUserMe } from "../../jwt"; import { SendWinnerEvent } from "../../Transport"; import { Layer } from "./Layer"; diff --git a/src/client/jwt.ts b/src/client/jwt.ts deleted file mode 100644 index 31cef3e2a..000000000 --- a/src/client/jwt.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { decodeJwt } from "jose"; -import { z } from "zod"; -import { - PlayerProfile, - PlayerProfileSchema, - RefreshResponseSchema, - TokenPayload, - TokenPayloadSchema, - UserMeResponse, - UserMeResponseSchema, -} from "../core/ApiSchemas"; -import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; - -function getAudience() { - const { hostname } = new URL(window.location.href); - const domainname = hostname.split(".").slice(-2).join("."); - return domainname; -} - -export function getApiBase() { - const domainname = getAudience(); - - if (domainname === "localhost") { - const apiDomain = process?.env?.API_DOMAIN; - if (apiDomain) { - return `https://${apiDomain}`; - } - return localStorage.getItem("apiHost") ?? "http://localhost:8787"; - } - - return `https://api.${domainname}`; -} - -function getToken(): string | null { - // Check cookie - const cookie = document.cookie - .split(";") - .find((c) => c.trim().startsWith("token=")) - ?.trim() - .substring(6); - if (cookie !== undefined) { - return cookie; - } - - // Check local storage - return localStorage.getItem("token"); -} - -async function clearToken() { - localStorage.removeItem("token"); - __isLoggedIn = false; - const config = await getServerConfigFromClient(); - const audience = config.jwtAudience(); - const isSecure = window.location.protocol === "https:"; - const secure = isSecure ? "; Secure" : ""; - document.cookie = `token=logged_out; Path=/; Max-Age=0; Domain=${audience}${secure}`; -} - -export function discordLogin() { - window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`; -} - -export async function tokenLogin(token: string): Promise { - const response = await fetch( - `${getApiBase()}/login/token?login-token=${token}`, - { - credentials: "include", - }, - ); - if (response.status !== 200) { - console.error("Token login failed", response); - return null; - } - const json = await response.json(); - const { email } = json; - return email; -} - -export function getAuthHeader(): string { - const token = getToken(); - if (!token) return ""; - return `Bearer ${token}`; -} - -export async function logOut(allSessions: boolean = false) { - const token = getToken(); - if (token === null) return; - clearToken(); - - const response = await fetch( - getApiBase() + (allSessions ? "/revoke" : "/logout"), - { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - }, - }, - ); - - if (response.ok === false) { - console.error("Logout failed", response); - return false; - } - return true; -} - -export type IsLoggedInResponse = - | { token: string; claims: TokenPayload } - | false; -let __isLoggedIn: IsLoggedInResponse | undefined = undefined; -export function isLoggedIn(): IsLoggedInResponse { - __isLoggedIn ??= _isLoggedIn(); - - return __isLoggedIn; -} -function _isLoggedIn(): IsLoggedInResponse { - try { - const token = getToken(); - if (!token) { - // console.log("No token found"); - return false; - } - - // Verify the JWT (requires browser support) - // const jwks = createRemoteJWKSet( - // new URL(getApiBase() + "/.well-known/jwks.json"), - // ); - // const { payload, protectedHeader } = await jwtVerify(token, jwks, { - // issuer: getApiBase(), - // audience: getAudience(), - // }); - - // Decode the JWT - const payload = decodeJwt(token); - const { iss, aud, exp, iat } = payload; - - if (iss !== getApiBase()) { - // JWT was not issued by the correct server - console.error( - 'unexpected "iss" claim value', - // JSON.stringify(payload, null, 2), - ); - logOut(); - return false; - } - const myAud = getAudience(); - if (myAud !== "localhost" && aud !== myAud) { - // JWT was not issued for this website - console.error( - 'unexpected "aud" claim value', - // JSON.stringify(payload, null, 2), - ); - logOut(); - return false; - } - const now = Math.floor(Date.now() / 1000); - if (exp !== undefined && now >= exp) { - // JWT expired - console.error( - 'after "exp" claim value', - // JSON.stringify(payload, null, 2), - ); - logOut(); - return false; - } - const refreshAge: number = 3 * 24 * 3600; // 3 days - if (iat !== undefined && now >= iat + refreshAge) { - console.log("Refreshing access token..."); - postRefresh().then((success) => { - if (success) { - console.log("Refreshed access token successfully."); - } else { - console.error("Failed to refresh access token."); - // TODO: Update the UI to show logged out state - } - }); - } - - const result = TokenPayloadSchema.safeParse(payload); - if (!result.success) { - const error = z.prettifyError(result.error); - // Invalid response - console.error("Invalid payload", error); - return false; - } - - const claims = result.data; - return { token, claims }; - } catch (e) { - console.log(e); - return false; - } -} - -export async function postRefresh(): Promise { - try { - const token = getToken(); - if (!token) return false; - - // Refresh the JWT - const response = await fetch(getApiBase() + "/refresh", { - method: "POST", - credentials: "include", - headers: { - authorization: `Bearer ${token}`, - }, - }); - if (response.status === 401) { - clearToken(); - return false; - } - if (response.status !== 200) return false; - const body = await response.json(); - const result = RefreshResponseSchema.safeParse(body); - if (!result.success) { - const error = z.prettifyError(result.error); - console.error("Invalid response", error); - return false; - } - localStorage.setItem("token", result.data.token); - // Clear the cached logged in state - // so that the next call to isLoggedIn() will refresh the token - __isLoggedIn = undefined; - return true; - } catch (e) { - __isLoggedIn = false; - return false; - } -} - -export async function getUserMe(): Promise { - try { - const token = getToken(); - if (!token) return false; - - // Get the user object - const response = await fetch(getApiBase() + "/users/@me", { - headers: { - authorization: `Bearer ${token}`, - }, - }); - if (response.status === 401) { - clearToken(); - return false; - } - if (response.status !== 200) return false; - const body = await response.json(); - const result = UserMeResponseSchema.safeParse(body); - if (!result.success) { - const error = z.prettifyError(result.error); - console.error("Invalid response", error); - return false; - } - return result.data; - } catch (e) { - __isLoggedIn = false; - return false; - } -} - -export async function fetchPlayerById( - playerId: string, -): Promise { - try { - const base = getApiBase(); - const token = getToken(); - if (!token) return false; - const url = `${base}/player/${encodeURIComponent(playerId)}`; - - const res = await fetch(url, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - }, - }); - - if (res.status !== 200) { - console.warn( - "fetchPlayerById: unexpected status", - res.status, - res.statusText, - ); - return false; - } - - const json = await res.json(); - const parsed = PlayerProfileSchema.safeParse(json); - if (!parsed.success) { - console.warn("fetchPlayerById: Zod validation failed", parsed.error); - return false; - } - - return parsed.data; - } catch (err) { - console.warn("fetchPlayerById: request failed", err); - return false; - } -}