mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:11:54 +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",
|
"failed_to_send_recovery_email": "Failed to send recovery email",
|
||||||
"fetching_account": "Fetching account information...",
|
"fetching_account": "Fetching account information...",
|
||||||
"get_magic_link": "Get Magic Link",
|
"get_magic_link": "Get Magic Link",
|
||||||
|
"google_alt": "Google",
|
||||||
"link_discord": "Link Discord Account",
|
"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}",
|
"linked_account": "Logged in as {account_name}",
|
||||||
"log_out": "Log Out",
|
"log_out": "Log Out",
|
||||||
"manage_subscription": "Manage",
|
"manage_subscription": "Manage",
|
||||||
@@ -807,6 +813,7 @@
|
|||||||
"join": "Join Lobby",
|
"join": "Join Lobby",
|
||||||
"leaderboard": "Leaderboard",
|
"leaderboard": "Leaderboard",
|
||||||
"login_discord": "Login with Discord",
|
"login_discord": "Login with Discord",
|
||||||
|
"login_google": "Login with Google",
|
||||||
"menu": "Menu",
|
"menu": "Menu",
|
||||||
"news": "News",
|
"news": "News",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
|
|||||||
+112
-4
@@ -9,7 +9,13 @@ import {
|
|||||||
import { assetUrl } from "../core/AssetUrls";
|
import { assetUrl } from "../core/AssetUrls";
|
||||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||||
import { fetchPlayerById, getUserMe } from "./Api";
|
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/DiscordUserHeader";
|
||||||
import "./components/baseComponents/stats/GameList";
|
import "./components/baseComponents/stats/GameList";
|
||||||
import "./components/baseComponents/stats/PlayerStatsTable";
|
import "./components/baseComponents/stats/PlayerStatsTable";
|
||||||
@@ -96,7 +102,7 @@ export class AccountModal extends BaseModal {
|
|||||||
|
|
||||||
private isLinkedAccount(): boolean {
|
private isLinkedAccount(): boolean {
|
||||||
const me = this.userMeResponse?.user;
|
const me = this.userMeResponse?.user;
|
||||||
return !!(me?.discord ?? me?.email);
|
return !!(me?.discord ?? me?.google ?? me?.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected modalConfig() {
|
protected modalConfig() {
|
||||||
@@ -252,6 +258,18 @@ export class AccountModal extends BaseModal {
|
|||||||
if (me?.discord) {
|
if (me?.discord) {
|
||||||
return html`
|
return html`
|
||||||
<div class="flex flex-col items-center gap-3 w-full">
|
<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()}
|
${this.renderCurrency()} ${this.renderLogoutButton()}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -263,13 +281,35 @@ export class AccountModal extends BaseModal {
|
|||||||
account_name: me.email,
|
account_name: me.email,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
${this.renderCurrency()} ${this.renderLogoutButton()}
|
${this.renderCurrency()} ${this.renderLinkGoogleButton()}
|
||||||
|
${this.renderLogoutButton()}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
return html``;
|
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> {
|
private async viewGame(gameId: string): Promise<void> {
|
||||||
this.close();
|
this.close();
|
||||||
const encodedGameId = encodeURIComponent(gameId);
|
const encodedGameId = encodeURIComponent(gameId);
|
||||||
@@ -340,6 +380,22 @@ export class AccountModal extends BaseModal {
|
|||||||
>
|
>
|
||||||
</button>
|
</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 -->
|
<!-- Divider -->
|
||||||
<div class="flex items-center gap-4 py-2">
|
<div class="flex items-center gap-4 py-2">
|
||||||
<div class="h-px bg-white/10 flex-1"></div>
|
<div class="h-px bg-white/10 flex-1"></div>
|
||||||
@@ -417,8 +473,60 @@ export class AccountModal extends BaseModal {
|
|||||||
discordLogin();
|
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.isLoadingUser = true;
|
||||||
|
this.handleLinkResult(args);
|
||||||
|
|
||||||
if (SUBSCRIPTIONS_ENABLED) {
|
if (SUBSCRIPTIONS_ENABLED) {
|
||||||
void fetchCosmetics().then((cosmetics) => {
|
void fetchCosmetics().then((cosmetics) => {
|
||||||
|
|||||||
+2
-1
@@ -282,13 +282,14 @@ export function getAudience() {
|
|||||||
return domainname;
|
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(
|
export function hasLinkedAccount(
|
||||||
userMeResponse: UserMeResponse | false,
|
userMeResponse: UserMeResponse | false,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
userMeResponse !== false &&
|
userMeResponse !== false &&
|
||||||
(userMeResponse.user?.discord !== undefined ||
|
(userMeResponse.user?.discord !== undefined ||
|
||||||
|
userMeResponse.user?.google !== undefined ||
|
||||||
userMeResponse.user?.email !== undefined)
|
userMeResponse.user?.email !== undefined)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,41 @@ export function discordLogin() {
|
|||||||
window.location.href = `${getApiBase()}/auth/login/discord?redirect_uri=${redirectUri}`;
|
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> {
|
export async function tempTokenLogin(token: string): Promise<string | null> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getApiBase()}/auth/login/token?login-token=${token}`,
|
`${getApiBase()}/auth/login/token?login-token=${token}`,
|
||||||
|
|||||||
@@ -162,6 +162,14 @@ function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
|
|||||||
return;
|
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();
|
showSignIn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ export class MatchmakingModal extends BaseModal {
|
|||||||
const isLoggedIn =
|
const isLoggedIn =
|
||||||
userMe &&
|
userMe &&
|
||||||
userMe.user &&
|
userMe.user &&
|
||||||
(userMe.user.discord !== undefined || userMe.user.email !== undefined);
|
(userMe.user.discord !== undefined ||
|
||||||
|
userMe.user.google !== undefined ||
|
||||||
|
userMe.user.email !== undefined);
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("show-message", {
|
new CustomEvent("show-message", {
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ export const DiscordUserSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type DiscordUser = z.infer<typeof DiscordUserSchema>;
|
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({
|
const SingleplayerMapAchievementSchema = z.object({
|
||||||
mapName: z.string(),
|
mapName: z.string(),
|
||||||
difficulty: z.enum(Difficulty),
|
difficulty: z.enum(Difficulty),
|
||||||
@@ -73,6 +78,7 @@ const SingleplayerMapAchievementSchema = z.object({
|
|||||||
export const UserMeResponseSchema = z.object({
|
export const UserMeResponseSchema = z.object({
|
||||||
user: z.object({
|
user: z.object({
|
||||||
discord: DiscordUserSchema.optional(),
|
discord: DiscordUserSchema.optional(),
|
||||||
|
google: GoogleUserSchema.optional(),
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
player: z.object({
|
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