mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-15 17:55:44 +00:00
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:
+112
-4
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user