mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +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:
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
|
||||
<path fill="none" d="M0 0h48v48H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 770 B |
@@ -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",
|
||||
|
||||
+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", {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user