diff --git a/resources/images/GoogleLogo.svg b/resources/images/GoogleLogo.svg
new file mode 100644
index 000000000..1f1839c51
--- /dev/null
+++ b/resources/images/GoogleLogo.svg
@@ -0,0 +1,7 @@
+
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 8b62a1fb2..353f6f420 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -12,7 +12,13 @@
"failed_to_send_recovery_email": "Failed to send recovery email",
"fetching_account": "Fetching account information...",
"get_magic_link": "Get Magic Link",
+ "google_alt": "Google",
"link_discord": "Link Discord Account",
+ "link_google": "Link Google Account",
+ "link_google_already_linked": "That Google account is already linked to another player.",
+ "link_google_error": "Couldn't link your Google account. Please make sure you're signed in and try again.",
+ "link_google_failed": "Couldn't start Google linking. Please try again.",
+ "link_google_success": "Google account linked.",
"linked_account": "Logged in as {account_name}",
"log_out": "Log Out",
"manage_subscription": "Manage",
@@ -807,6 +813,7 @@
"join": "Join Lobby",
"leaderboard": "Leaderboard",
"login_discord": "Login with Discord",
+ "login_google": "Login with Google",
"menu": "Menu",
"news": "News",
"play": "Play",
diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts
index e1aa2f0d1..f5dedcc21 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -9,7 +9,13 @@ import {
import { assetUrl } from "../core/AssetUrls";
import { Cosmetics } from "../core/CosmeticSchemas";
import { fetchPlayerById, getUserMe } from "./Api";
-import { discordLogin, logOut, sendMagicLink } from "./Auth";
+import {
+ discordLogin,
+ googleLogin,
+ linkGoogle,
+ logOut,
+ sendMagicLink,
+} from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
import "./components/baseComponents/stats/PlayerStatsTable";
@@ -96,7 +102,7 @@ export class AccountModal extends BaseModal {
private isLinkedAccount(): boolean {
const me = this.userMeResponse?.user;
- return !!(me?.discord ?? me?.email);
+ return !!(me?.discord ?? me?.google ?? me?.email);
}
protected modalConfig() {
@@ -252,6 +258,18 @@ export class AccountModal extends BaseModal {
if (me?.discord) {
return html`
+ ${this.renderCurrency()} ${this.renderLinkGoogleButton()}
+ ${this.renderLogoutButton()}
+
+ `;
+ } else if (me?.google) {
+ return html`
+
+
+ ${translateText("account_modal.linked_account", {
+ account_name: me.google.email,
+ })}
+
${this.renderCurrency()} ${this.renderLogoutButton()}
`;
@@ -263,13 +281,35 @@ export class AccountModal extends BaseModal {
account_name: me.email,
})}
- ${this.renderCurrency()} ${this.renderLogoutButton()}
+ ${this.renderCurrency()} ${this.renderLinkGoogleButton()}
+ ${this.renderLogoutButton()}
`;
}
return html``;
}
+ // Shown when logged in without a Google identity yet. Lets the user attach
+ // Google to their existing account (we never auto-merge by email).
+ private renderLinkGoogleButton(): TemplateResult {
+ if (this.userMeResponse?.user?.google) return html``;
+ return html`
+
+ `;
+ }
+
private async viewGame(gameId: string): Promise {
this.close();
const encodedGameId = encodeURIComponent(gameId);
@@ -340,6 +380,22 @@ export class AccountModal extends BaseModal {
>
+
+
+
@@ -417,8 +473,60 @@ export class AccountModal extends BaseModal {
discordLogin();
}
- protected onOpen(): void {
+ private handleGoogleLogin() {
+ googleLogin();
+ }
+
+ private async handleLinkGoogle(): Promise
{
+ // On success linkGoogle navigates to Google; the result comes back as a
+ // `link=...` router arg handled in handleLinkResult. A false return means we
+ // couldn't start it.
+ const started = await linkGoogle();
+ if (!started) {
+ alert(translateText("account_modal.link_google_failed"));
+ }
+ }
+
+ // The Google link callback returns us to #modal=account&link=, so the
+ // router reopens this modal with a `link` arg. Surface the outcome, then strip
+ // the one-shot param from the URL so a refresh/re-open doesn't replay it.
+ private handleLinkResult(args?: Record): void {
+ const link = typeof args?.link === "string" ? args.link : undefined;
+ if (link === undefined) return;
+
+ // replaceState doesn't fire hashchange, so removing the param won't re-route.
+ const params = new URLSearchParams(window.location.hash.slice(1));
+ params.delete("link");
+ const rest = params.toString();
+ history.replaceState(
+ null,
+ "",
+ rest ? `#${rest}` : window.location.pathname + window.location.search,
+ );
+
+ // Defer so the modal paints before the (blocking) alert. "cancel" needs no
+ // feedback — the user chose to back out.
+ if (link === "google") {
+ setTimeout(
+ () => alert(translateText("account_modal.link_google_success")),
+ 0,
+ );
+ } else if (link === "already_linked") {
+ setTimeout(
+ () => alert(translateText("account_modal.link_google_already_linked")),
+ 0,
+ );
+ } else if (link === "error") {
+ setTimeout(
+ () => alert(translateText("account_modal.link_google_error")),
+ 0,
+ );
+ }
+ }
+
+ protected onOpen(args?: Record): void {
this.isLoadingUser = true;
+ this.handleLinkResult(args);
if (SUBSCRIPTIONS_ENABLED) {
void fetchCosmetics().then((cosmetics) => {
diff --git a/src/client/Api.ts b/src/client/Api.ts
index 8fc5f0143..9659ed9a1 100644
--- a/src/client/Api.ts
+++ b/src/client/Api.ts
@@ -282,13 +282,14 @@ export function getAudience() {
return domainname;
}
-// Check if the user's account is linked to a Discord or email account.
+// Check if the user's account is linked to a Discord, Google, or email account.
export function hasLinkedAccount(
userMeResponse: UserMeResponse | false,
): boolean {
return (
userMeResponse !== false &&
(userMeResponse.user?.discord !== undefined ||
+ userMeResponse.user?.google !== undefined ||
userMeResponse.user?.email !== undefined)
);
}
diff --git a/src/client/Auth.ts b/src/client/Auth.ts
index be0899146..e2b51e382 100644
--- a/src/client/Auth.ts
+++ b/src/client/Auth.ts
@@ -19,6 +19,41 @@ export function discordLogin() {
window.location.href = `${getApiBase()}/auth/login/discord?redirect_uri=${redirectUri}`;
}
+export function googleLogin() {
+ const redirectUri = encodeURIComponent(window.location.href);
+ window.location.href = `${getApiBase()}/auth/login/google?redirect_uri=${redirectUri}`;
+}
+
+// Link a Google account to the currently logged-in player. Unlike login this is
+// an authenticated request, so we fetch the Google authorize URL with the
+// Bearer token (a top-level navigation can't carry it) and then navigate to it.
+// Returns false if the user isn't logged in or the request fails.
+export async function linkGoogle(): Promise {
+ const authHeader = await getAuthHeader();
+ if (authHeader === "") return false;
+ const redirectUri = encodeURIComponent(window.location.href);
+ try {
+ const response = await fetch(
+ `${getApiBase()}/auth/link/google?redirect_uri=${redirectUri}`,
+ {
+ headers: { Authorization: authHeader },
+ credentials: "include",
+ },
+ );
+ if (!response.ok) {
+ console.error("Failed to start Google link", response);
+ return false;
+ }
+ const { url } = await response.json();
+ if (typeof url !== "string") return false;
+ window.location.href = url;
+ return true;
+ } catch (e) {
+ console.error("Failed to start Google link", e);
+ return false;
+ }
+}
+
export async function tempTokenLogin(token: string): Promise {
const response = await fetch(
`${getApiBase()}/auth/login/token?login-token=${token}`,
diff --git a/src/client/Main.ts b/src/client/Main.ts
index ebfef8a41..b3dabb9ad 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -162,6 +162,14 @@ function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
return;
}
+ // Google logins have no avatar; show the same person/email badge as magic-link.
+ const google =
+ userMeResponse !== false ? userMeResponse.user.google : undefined;
+ if (google) {
+ showEmailLoggedIn();
+ return;
+ }
+
showSignIn();
}
diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts
index 146a6cf1e..ed848430e 100644
--- a/src/client/Matchmaking.ts
+++ b/src/client/Matchmaking.ts
@@ -122,7 +122,9 @@ export class MatchmakingModal extends BaseModal {
const isLoggedIn =
userMe &&
userMe.user &&
- (userMe.user.discord !== undefined || userMe.user.email !== undefined);
+ (userMe.user.discord !== undefined ||
+ userMe.user.google !== undefined ||
+ userMe.user.email !== undefined);
if (!isLoggedIn) {
window.dispatchEvent(
new CustomEvent("show-message", {
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 828306b2c..b9ac797ff 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -65,6 +65,11 @@ export const DiscordUserSchema = z.object({
});
export type DiscordUser = z.infer;
+export const GoogleUserSchema = z.object({
+ email: z.string(),
+});
+export type GoogleUser = z.infer;
+
const SingleplayerMapAchievementSchema = z.object({
mapName: z.string(),
difficulty: z.enum(Difficulty),
@@ -73,6 +78,7 @@ const SingleplayerMapAchievementSchema = z.object({
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
+ google: GoogleUserSchema.optional(),
email: z.string().optional(),
}),
player: z.object({
diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts
new file mode 100644
index 000000000..79740633b
--- /dev/null
+++ b/tests/ApiSchemas.test.ts
@@ -0,0 +1,25 @@
+import { GoogleUser, GoogleUserSchema } from "../src/core/ApiSchemas";
+
+describe("GoogleUserSchema", () => {
+ it("accepts a valid email", () => {
+ const result = GoogleUserSchema.safeParse({ email: "user@example.com" });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.email).toBe("user@example.com");
+ }
+ });
+
+ it("rejects a missing email", () => {
+ expect(GoogleUserSchema.safeParse({}).success).toBe(false);
+ });
+
+ it("rejects a non-string email", () => {
+ expect(GoogleUserSchema.safeParse({ email: 123 }).success).toBe(false);
+ });
+
+ it("infers the GoogleUser type from the schema", () => {
+ // Compile-time check that GoogleUser is derived from the schema.
+ const user: GoogleUser = { email: "typed@example.com" };
+ expect(user.email).toBe("typed@example.com");
+ });
+});
diff --git a/tests/HasLinkedAccount.test.ts b/tests/HasLinkedAccount.test.ts
new file mode 100644
index 000000000..bb06a5fa3
--- /dev/null
+++ b/tests/HasLinkedAccount.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { hasLinkedAccount } from "../src/client/Api";
+import { UserMeResponse } from "../src/core/ApiSchemas";
+
+// hasLinkedAccount gates the top bar, matchmaking, single-player, ranked, and
+// the skins-page "not logged in" warning. A Google login sets user.google (no
+// discord/email), so it must count as logged in — otherwise the UI shows
+// "Sign in" despite a valid session (regression: Google users seen as logged
+// out everywhere except the account modal).
+function userWith(user: Record): UserMeResponse {
+ return { user } as unknown as UserMeResponse;
+}
+
+describe("hasLinkedAccount", () => {
+ it("returns false when not logged in", () => {
+ expect(hasLinkedAccount(false)).toBe(false);
+ });
+
+ it("returns false when the user has no linked identity", () => {
+ expect(hasLinkedAccount(userWith({}))).toBe(false);
+ });
+
+ it("recognizes a Discord login", () => {
+ expect(hasLinkedAccount(userWith({ discord: { id: "1" } }))).toBe(true);
+ });
+
+ it("recognizes an email (magic-link) login", () => {
+ expect(hasLinkedAccount(userWith({ email: "a@example.com" }))).toBe(true);
+ });
+
+ it("recognizes a Google login", () => {
+ expect(
+ hasLinkedAccount(userWith({ google: { email: "a@example.com" } })),
+ ).toBe(true);
+ });
+});