mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
Update auth & login to follow best practices (#2559)
## Description: The previous login system used long lived jwts which could be stolen by XSS. The current system uses long lived refresh tokens that are stored as http-only cookies. Then the client calls /refresh to get a short lived jwt using the refresh token. The jwt is stored in memory only so it's discarded on page close. This way a XSS can only steal the short-lived jwt. It also updates how accounts work: players get an account automatically when they join the webpage. They can see their stats even if not logged in. If a player wants to keep their account, they can tie it to their Discord or email, allowing them to log in if cookies are lost. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -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."
|
||||
|
||||
+97
-111
@@ -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`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-6 text-white"
|
||||
>
|
||||
<p class="mb-2">
|
||||
${translateText("account_modal.fetching_account")}
|
||||
</p>
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`
|
||||
: this.renderInner()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderInner() {
|
||||
if (this.isLoadingUser) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-2">${translateText("account_modal.fetching_account")}</p>
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
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`
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<p class="text-white mb-4 text-center">
|
||||
${translateText("account_modal.player_id", {
|
||||
id:
|
||||
this.userMeResponse?.player?.publicId ??
|
||||
translateText("account_modal.not_found"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-4 text-center">
|
||||
<p class="text-white mb-4">${this.renderLoggedInAs()}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center mt-2 mb-4">
|
||||
<discord-user-header
|
||||
.data=${this.userMeResponse?.user?.discord ?? null}
|
||||
></discord-user-header>
|
||||
</div>
|
||||
${this.renderPlayerStats()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLoggedInAs(): TemplateResult {
|
||||
const me = this.userMeResponse?.user;
|
||||
if (me?.discord) {
|
||||
return html`<p>
|
||||
${translateText("account_modal.logged_in_as", {
|
||||
account_name: me.discord.global_name ?? "",
|
||||
})}
|
||||
</p>
|
||||
${this.renderLogoutButton()}`;
|
||||
} else if (me?.email) {
|
||||
return html`<p>
|
||||
${translateText("account_modal.logged_in_as", {
|
||||
account_name: me.email,
|
||||
})}
|
||||
</p>
|
||||
${this.renderLogoutButton()}`;
|
||||
}
|
||||
return this.renderLoginOptions();
|
||||
}
|
||||
|
||||
private renderPlayerStats(): TemplateResult {
|
||||
return html`
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
<hr class="w-2/3 border-gray-600 my-2" />
|
||||
<game-list
|
||||
.games=${this.recentGames}
|
||||
.onViewGame=${(id: string) => this.viewGame(id)}
|
||||
></game-list>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="p-6">
|
||||
<div class="mb-4 text-center">
|
||||
<p class="text-white mb-4">
|
||||
Logged in with Discord as ${this.loggedInDiscord}
|
||||
</p>
|
||||
${this.logoutButton()}
|
||||
</div>
|
||||
<div class="flex flex-col items-center mt-2 mb-4">
|
||||
<discord-user-header
|
||||
.data=${this.userMeResponse?.user?.discord ?? null}
|
||||
></discord-user-header>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
<hr class="w-2/3 border-gray-600 my-2" />
|
||||
<game-list
|
||||
.games=${this.recentGames}
|
||||
.onViewGame=${(id: string) => this.viewGame(id)}
|
||||
></game-list>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLoggedInEmail(): TemplateResult {
|
||||
return html`
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<p class="text-white text-center mb-4">
|
||||
Logged in as ${this.loggedInEmail}
|
||||
</p>
|
||||
</div>
|
||||
${this.logoutButton()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private logoutButton(): TemplateResult {
|
||||
private renderLogoutButton(): TemplateResult {
|
||||
return html`
|
||||
<button
|
||||
@click="${this.handleLogout}"
|
||||
@@ -157,10 +168,6 @@ export class AccountModal extends LitElement {
|
||||
return html`
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-white mb-4 text-center">
|
||||
Choose your login method
|
||||
</h3>
|
||||
|
||||
<!-- Discord Login Button -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@@ -195,7 +202,6 @@ export class AccountModal extends LitElement {
|
||||
for="email"
|
||||
class="block text-sm font-medium text-white mb-2"
|
||||
>
|
||||
Recover account by email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -225,6 +231,12 @@ export class AccountModal extends LitElement {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="${this.handleLogout}"
|
||||
class="px-3 py-1 text-xs font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
|
||||
>
|
||||
${translateText("account_modal.clear_session")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
private async loadPlayerProfile(publicId: string): Promise<void> {
|
||||
try {
|
||||
const data = await fetchPlayerById(playerId);
|
||||
const data = await fetchPlayerById(publicId);
|
||||
if (!data) {
|
||||
this.requestUpdate();
|
||||
return;
|
||||
|
||||
@@ -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<PlayerProfile | false> {
|
||||
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<UserMeResponse | false> {
|
||||
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<string | false> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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<string> {
|
||||
const userAuthResult = await userAuth();
|
||||
if (!userAuthResult) return "";
|
||||
const { jwt } = userAuthResult;
|
||||
return `Bearer ${jwt}`;
|
||||
}
|
||||
|
||||
export async function logOut(allSessions: boolean = false): Promise<boolean> {
|
||||
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<boolean> {
|
||||
const userAuthResult = await userAuth();
|
||||
return userAuthResult !== false;
|
||||
}
|
||||
|
||||
export async function userAuth(
|
||||
shouldRefresh: boolean = true,
|
||||
): Promise<UserAuth> {
|
||||
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<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;
|
||||
}
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+6
-29
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+6
-49
@@ -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<void> {
|
||||
// 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;
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -134,17 +134,9 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
return html`
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
|
||||
.showOnlyOwned
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
|
||||
@click=${() => {
|
||||
this.showOnlyOwned = !this.showOnlyOwned;
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.show_only_owned")}
|
||||
</button>
|
||||
${this.isLoggedIn()
|
||||
? this.renderMySkinsButton()
|
||||
: this.renderNotLoggedInWarning()}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2"
|
||||
@@ -164,6 +156,28 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMySkinsButton(): TemplateResult {
|
||||
return html`<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
|
||||
.showOnlyOwned
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
|
||||
@click=${() => {
|
||||
this.showOnlyOwned = !this.showOnlyOwned;
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.show_only_owned")}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private renderNotLoggedInWarning(): TemplateResult {
|
||||
return html`<label
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg bg-red-500 text-white"
|
||||
>
|
||||
${translateText("territory_patterns.not_logged_in")}
|
||||
</label>`;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<boolean> {
|
||||
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<UserMeResponse | false> {
|
||||
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<PlayerProfile | false> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user