From ff5eb786892ad1432c145b3215433b82c576cf5c Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Fri, 19 Jun 2026 11:47:40 +0100 Subject: [PATCH] =?UTF-8?q?Login=20with=20Google=20=E2=80=94=20client=20UI?= =?UTF-8?q?=20(#4028)=20(#4279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #4028 (client half — backend is openfrontio/infra#368, which must be deployed first). ## Description: Adds "Login with Google" to the client, alongside the existing Discord login. Companion to the backend PR (openfrontio/infra#368). - `Auth.ts` — `googleLogin()` (full-page redirect to `/auth/login/google?redirect_uri=…`, mirrors `discordLogin()`). - `ApiSchemas.ts` — `GoogleUserSchema` + optional `user.google` on `UserMeResponseSchema`. - `AccountModal.ts` — a "Login with Google" button (Google brand guidelines: white surface, dark text, the multicolor "G" mark) in the login options, and the logged-in view now renders a Google-authenticated user's email (also added `google` to `isLinkedAccount()`). - `en.json` — `main.login_google`. - `resources/images/GoogleLogo.svg` — the Google "G" mark. > **Draft.** Depends on infra#368 being deployed (the button hits the live `/auth/login/google`). ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: jish --- resources/images/GoogleLogo.svg | 7 ++ resources/lang/en.json | 7 ++ src/client/AccountModal.ts | 116 ++++++++++++++++++++++++++++++-- src/client/Api.ts | 3 +- src/client/Auth.ts | 35 ++++++++++ src/client/Main.ts | 8 +++ src/client/Matchmaking.ts | 4 +- src/core/ApiSchemas.ts | 6 ++ tests/ApiSchemas.test.ts | 25 +++++++ tests/HasLinkedAccount.test.ts | 36 ++++++++++ 10 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 resources/images/GoogleLogo.svg create mode 100644 tests/ApiSchemas.test.ts create mode 100644 tests/HasLinkedAccount.test.ts 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); + }); +});