Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -87,6 +87,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 @@ -68,6 +68,7 @@ import {
isInIframe,
translateText,
} from "./Utils";
import "./components/BannedModal";
import "./components/MarketingConsentToast";
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";

Expand Down
93 changes: 93 additions & 0 deletions src/client/components/BannedModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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;
if (detail === false || !detail.ban) 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
102 changes: 102 additions & 0 deletions tests/client/BannedModal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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("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