Login with Google — client UI (#4028) (#4279)

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 <!-- TODO: add
screenshot of the Google button -->
- [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 <!-- no client
tests exist for AccountModal/Auth; verified via tsc --noEmit + eslint.
Backend behaviour is covered in infra#368 -->

## Please put your Discord username so you can be contacted if a bug or
regression is found:

jish
This commit is contained in:
Josh Harris
2026-06-19 11:47:40 +01:00
committed by GitHub
parent 21291b9fa3
commit ff5eb78689
10 changed files with 241 additions and 6 deletions
+112 -4
View File
@@ -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`
<div class="flex flex-col items-center gap-3 w-full">
${this.renderCurrency()} ${this.renderLinkGoogleButton()}
${this.renderLogoutButton()}
</div>
`;
} else if (me?.google) {
return html`
<div class="flex flex-col items-center gap-3 w-full">
<div class="text-white text-lg font-medium">
${translateText("account_modal.linked_account", {
account_name: me.google.email,
})}
</div>
${this.renderCurrency()} ${this.renderLogoutButton()}
</div>
`;
@@ -263,13 +281,35 @@ export class AccountModal extends BaseModal {
account_name: me.email,
})}
</div>
${this.renderCurrency()} ${this.renderLogoutButton()}
${this.renderCurrency()} ${this.renderLinkGoogleButton()}
${this.renderLogoutButton()}
</div>
`;
}
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`
<button
@click=${this.handleLinkGoogle}
class="w-full px-6 py-3 text-[#1f1f1f] bg-white hover:bg-[#f7f8f8] border border-[#dadce0] rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4285F4] transition-colors duration-200 flex items-center justify-center gap-3 shadow-lg"
>
<img
src=${assetUrl("images/GoogleLogo.svg")}
alt=${translateText("account_modal.google_alt")}
class="w-5 h-5"
/>
<span class="font-bold tracking-wide"
>${translateText("account_modal.link_google")}</span
>
</button>
`;
}
private async viewGame(gameId: string): Promise<void> {
this.close();
const encodedGameId = encodeURIComponent(gameId);
@@ -340,6 +380,22 @@ export class AccountModal extends BaseModal {
>
</button>
<!-- Google Login Button (Google brand guidelines: white surface,
dark text, the multicolor "G" mark) -->
<button
@click="${this.handleGoogleLogin}"
class="w-full px-6 py-4 text-[#1f1f1f] bg-white hover:bg-[#f7f8f8] border border-[#dadce0] rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4285F4] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg"
>
<img
src=${assetUrl("images/GoogleLogo.svg")}
alt="Google"
class="w-6 h-6 relative z-10"
/>
<span class="font-bold relative z-10 tracking-wide"
>${translateText("main.login_google")}</span
>
</button>
<!-- Divider -->
<div class="flex items-center gap-4 py-2">
<div class="h-px bg-white/10 flex-1"></div>
@@ -417,8 +473,60 @@ export class AccountModal extends BaseModal {
discordLogin();
}
protected onOpen(): void {
private handleGoogleLogin() {
googleLogin();
}
private async handleLinkGoogle(): Promise<void> {
// 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=<result>, 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<string, unknown>): 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<string, unknown>): void {
this.isLoadingUser = true;
this.handleLinkResult(args);
if (SUBSCRIPTIONS_ENABLED) {
void fetchCosmetics().then((cosmetics) => {
+2 -1
View File
@@ -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)
);
}
+35
View File
@@ -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<boolean> {
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<string | null> {
const response = await fetch(
`${getApiBase()}/auth/login/token?login-token=${token}`,
+8
View File
@@ -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();
}
+3 -1
View File
@@ -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", {
+6
View File
@@ -65,6 +65,11 @@ export const DiscordUserSchema = z.object({
});
export type DiscordUser = z.infer<typeof DiscordUserSchema>;
export const GoogleUserSchema = z.object({
email: z.string(),
});
export type GoogleUser = z.infer<typeof GoogleUserSchema>;
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({