Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
></div>

<homepage-promos></homepage-promos>
<banned-modal></banned-modal>
<marketing-consent-toast></marketing-consent-toast>

<!-- Main container with responsive padding -->
Expand Down
21 changes: 21 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
isInIframe,
translateText,
} from "./Utils";
import "./components/BannedModal";
import "./components/MarketingConsentToast";
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";

Expand Down
99 changes: 99 additions & 0 deletions src/client/components/BannedModal.ts
Original file line number Diff line number Diff line change
@@ -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<NonNullable<UserMeResponse["ban"]>>;

/**
* 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 <marketing-consent-toast>. 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<UserMeResponse | false>).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;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 <o-modal> 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`
<o-modal title=${translateText("ban_notice.title")}>
<div style="max-width: 32rem; line-height: 1.5;">
<p style="font-weight: 600; font-size: 1.05rem; margin: 0 0 0.5rem;">
${this.categoryLabel(ban.category)}
</p>
${ban.reason
? html`<p style="margin: 0 0 0.5rem;">
${translateText("ban_notice.reason", { reason: ban.reason })}
</p>`
: null}
<p style="margin: 0 0 0.75rem;">
${ban.expiresAt
? translateText("ban_notice.until", {
date: new Date(ban.expiresAt).toLocaleString(),
})
: translateText("ban_notice.permanent")}
</p>
<p style="margin: 0; opacity: 0.8;">
${translateText("ban_notice.appeal")}
</p>
</div>
</o-modal>
`;
}
}
12 changes: 12 additions & 0 deletions src/core/ApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
33 changes: 33 additions & 0 deletions tests/ApiSchemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
115 changes: 115 additions & 0 deletions tests/client/BannedModal.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>) => {
// 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<string> {
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");
});
});
Loading