diff --git a/index.html b/index.html index 51e4d82009..7c46d9cbaa 100644 --- a/index.html +++ b/index.html @@ -206,6 +206,7 @@ > + diff --git a/resources/lang/en.json b/resources/lang/en.json index bf2eb1cf6d..e133e75e86 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -84,6 +84,27 @@ "username_title": "Username", "your_subscription": "Your Subscription" }, + "ban_notice": { + "appeal": "If you believe this is a mistake, contact us on Discord.", + "category": { + "cheating": "Cheating or unauthorised software", + "child_safety": "Child-safety violation", + "disruptive_play": "Disruptive play, griefing or spam", + "exploiting": "Exploiting bugs", + "harassment": "Harassment, threats or abuse", + "hate_speech": "Hate speech or discrimination", + "inappropriate_content": "Inappropriate or explicit content", + "multi_accounting": "Multi-accounting / ban evasion", + "offensive_identifier": "Offensive username, clan tag or flag", + "other": "Terms of Service violation", + "rank_manipulation": "Rank or rating manipulation", + "teaming": "Teaming or collusion" + }, + "permanent": "This ban is permanent.", + "reason": "Reason: {reason}", + "title": "Your account is banned", + "until": "Banned until {date}." + }, "build_menu": { "desc": { "atom_bomb": "Small explosion", diff --git a/src/client/Main.ts b/src/client/Main.ts index 39e609c8ed..133a611334 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -69,6 +69,7 @@ import { isInIframe, translateText, } from "./Utils"; +import "./components/BannedModal"; import "./components/MarketingConsentToast"; import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom"; diff --git a/src/client/components/BannedModal.ts b/src/client/components/BannedModal.ts new file mode 100644 index 0000000000..f4ca185c85 --- /dev/null +++ b/src/client/components/BannedModal.ts @@ -0,0 +1,99 @@ +import { html, LitElement } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import type { UserMeResponse } from "../../core/ApiSchemas"; +import { translateText } from "../Utils"; +import "./baseComponents/Modal"; +import type { OModal } from "./baseComponents/Modal"; + +type Ban = NonNullable>; + +/** + * Shows a banned player *why* they're banned. The game server already refuses a + * banned account's WebSocket ("Account Banned"); this surfaces the rich reason + * the API returns on GET /users/@me (`ban`) — a localized category, the + * moderator's player-facing reason, and when the ban lifts (or that it's + * permanent). + * + * Reads the `userMeResponse` document event (dispatched after login / on load), + * like . It opens the modal once; if the player closes + * it, it stays closed for the session (they still can't play — the server + * enforces that — but they aren't trapped behind the dialog). + */ +@customElement("banned-modal") +export class BannedModal extends LitElement { + @state() private ban: Ban | null = null; + @query("o-modal") private modalEl?: OModal; + private opened = false; + + private onUserMeResponse = (event: Event) => { + const detail = (event as CustomEvent).detail; + // No ban (or @me failed) clears the notice — e.g. an unban that lands in + // the same session — and re-arms `opened` so a later re-ban reopens it. + if (detail === false || !detail.ban) { + this.ban = null; + this.opened = false; + return; + } + this.ban = detail.ban; + }; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("userMeResponse", this.onUserMeResponse); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("userMeResponse", this.onUserMeResponse); + } + + updated() { + // Open once. The `open` guard keeps this resilient where isn't + // registered (unit tests) and stops a re-open after the player closes it. + if (this.ban && !this.opened && typeof this.modalEl?.open === "function") { + this.opened = true; + this.modalEl.open(); + } + } + + // Map the server category to its label, falling back to the generic ToS + // wording for a value a newer server introduced that this client can't name. + private categoryLabel(category: string): string { + const key = `ban_notice.category.${category}`; + const label = translateText(key); + return label === key ? translateText("ban_notice.category.other") : label; + } + + render() { + const ban = this.ban; + if (!ban) return html``; + return html` + +
+

+ ${this.categoryLabel(ban.category)} +

+ ${ban.reason + ? html`

+ ${translateText("ban_notice.reason", { reason: ban.reason })} +

` + : null} +

+ ${ban.expiresAt + ? translateText("ban_notice.until", { + date: new Date(ban.expiresAt).toLocaleString(), + }) + : translateText("ban_notice.permanent")} +

+

+ ${translateText("ban_notice.appeal")} +

+
+
+ `; + } +} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 8df272c5cd..d3b0f83583 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -151,6 +151,18 @@ export const UserMeResponseSchema = z.object({ google: GoogleUserSchema.optional(), email: z.string().optional(), }), + // The caller's active account ban, shown to them (localized client-side), or + // null. `category` is a server enum but kept as a string here so an + // unrecognised value degrades gracefully. Optional so an older API that + // predates the field is treated as "no ban". + ban: z + .object({ + category: z.string(), + reason: z.string().nullable(), + expiresAt: z.iso.datetime().nullable(), + }) + .nullable() + .optional(), player: z.object({ publicId: z.string(), adfree: z.boolean(), diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index 7326514343..ce1d156455 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -18,6 +18,39 @@ import { UserMeResponseSchema, } from "../src/core/ApiSchemas"; +describe("UserMeResponseSchema ban", () => { + const ban = UserMeResponseSchema.shape.ban; + + it("accepts an active temporary ban", () => { + const r = ban.safeParse({ + category: "cheating", + reason: "aimbot in ranked", + expiresAt: "2026-08-01T00:00:00.000Z", + }); + expect(r.success).toBe(true); + }); + + it("accepts a permanent ban (null reason and expiry)", () => { + const r = ban.safeParse({ + category: "other", + reason: null, + expiresAt: null, + }); + expect(r.success).toBe(true); + }); + + it("treats null and a missing field as 'no ban'", () => { + expect(ban.safeParse(null).success).toBe(true); + expect(ban.safeParse(undefined).success).toBe(true); + }); + + it("rejects a ban with no category", () => { + expect(ban.safeParse({ reason: null, expiresAt: null }).success).toBe( + false, + ); + }); +}); + describe("GoogleUserSchema", () => { it("accepts a valid email", () => { const result = GoogleUserSchema.safeParse({ email: "user@example.com" }); diff --git a/tests/client/BannedModal.test.ts b/tests/client/BannedModal.test.ts new file mode 100644 index 0000000000..d4494b1035 --- /dev/null +++ b/tests/client/BannedModal.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// translateText stand-in that mirrors the real one's key-resolution: a known +// key renders to `[key]` (so it differs from the key — the component's +// unknown-category fallback keys off `label === key`), an UNKNOWN key returns +// the key unchanged (simulating a missing translation), and params are appended +// so assertions can see interpolated values. +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string, params?: Record) => { + // Only this category has no translation, to exercise the fallback path. + if (key === "ban_notice.category.brand_new_reason") return key; + return params + ? `[${key}] ${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join(",")}` + : `[${key}]`; + }, +})); + +import { BannedModal } from "../../src/client/components/BannedModal"; + +function fireUserMe(detail: unknown) { + document.dispatchEvent(new CustomEvent("userMeResponse", { detail })); +} + +describe("banned-modal", () => { + let el: BannedModal; + + beforeEach(async () => { + // The @customElement define() side-effect doesn't run under the test + // transform, so register explicitly (as other client component tests do). + if (!customElements.get("banned-modal")) { + customElements.define("banned-modal", BannedModal); + } + el = document.createElement("banned-modal") as BannedModal; + document.body.appendChild(el); + await el.updateComplete; + }); + + afterEach(() => { + el.remove(); + vi.clearAllMocks(); + }); + + async function text(): Promise { + await el.updateComplete; + return el.textContent ?? ""; + } + + it("renders nothing when the player is not banned", async () => { + fireUserMe({ ban: null, user: {}, player: {} }); + expect((await text()).trim()).toBe(""); + }); + + it("renders nothing when @me failed to load", async () => { + fireUserMe(false); + expect((await text()).trim()).toBe(""); + }); + + it("shows the localized category, reason and lift date for a temp ban", async () => { + fireUserMe({ + ban: { + category: "cheating", + reason: "aimbot in ranked", + expiresAt: "2026-08-01T00:00:00.000Z", + }, + user: {}, + player: {}, + }); + const t = await text(); + // Title is set on the modal shell (an attribute), the rest is body text. + expect(el.querySelector("o-modal")?.getAttribute("title")).toContain( + "ban_notice.title", + ); + expect(t).toContain("ban_notice.category.cheating"); + expect(t).toContain("reason=aimbot in ranked"); + expect(t).toContain("ban_notice.until"); + expect(t).not.toContain("ban_notice.permanent"); + }); + + it("shows 'permanent' and no reason line for a permanent ban with no reason", async () => { + fireUserMe({ + ban: { category: "other", reason: null, expiresAt: null }, + user: {}, + player: {}, + }); + const t = await text(); + expect(t).toContain("ban_notice.category.other"); + expect(t).toContain("ban_notice.permanent"); + expect(t).not.toContain("ban_notice.reason"); + }); + + it("clears the notice when a later @me arrives with no ban (unbanned in-session)", async () => { + fireUserMe({ + ban: { category: "cheating", reason: null, expiresAt: null }, + user: {}, + player: {}, + }); + expect(await text()).toContain("ban_notice.category.cheating"); + + // The player is unbanned and @me re-dispatches without a ban. + fireUserMe({ ban: null, user: {}, player: {} }); + expect((await text()).trim()).toBe(""); + }); + + it("falls back to the generic category for an unknown value", async () => { + fireUserMe({ + ban: { category: "brand_new_reason", reason: null, expiresAt: null }, + user: {}, + player: {}, + }); + // Unknown key → translateText returns the key unchanged → fallback to other. + expect(await text()).toContain("ban_notice.category.other"); + }); +});