mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Improve ingame moderation for admins (#3678)
## Description: Players with the `admin` flare can now kick players from any game (including public lobbies), not just the lobby creator in private lobbies. ## Please complete the following: - [x] I have added screenshots for all UI updates - [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 - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n
This commit is contained in:
@@ -882,6 +882,7 @@
|
||||
"kick_reason": {
|
||||
"duplicate_session": "Kicked from game (you may have been playing on another tab)",
|
||||
"lobby_creator": "Kicked by lobby creator",
|
||||
"admin": "Kicked by an admin",
|
||||
"host_left": "The host has left the lobby."
|
||||
},
|
||||
"send_troops_modal": {
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface LobbyConfig {
|
||||
cosmetics: PlayerCosmeticRefs;
|
||||
playerName: string;
|
||||
playerClanTag: string | null;
|
||||
playerRole: string | null;
|
||||
gameID: GameID;
|
||||
turnstileToken: string | null;
|
||||
// GameStartInfo only exists when playing a singleplayer game.
|
||||
@@ -259,7 +260,12 @@ async function createClientGame(
|
||||
const canvas = createCanvas();
|
||||
const soundManager = new SoundManager(eventBus, userSettings);
|
||||
try {
|
||||
const gameRenderer = createRenderer(canvas, gameView, eventBus);
|
||||
const gameRenderer = createRenderer(
|
||||
canvas,
|
||||
gameView,
|
||||
eventBus,
|
||||
lobbyConfig.playerRole,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`,
|
||||
|
||||
@@ -756,6 +756,8 @@ class Client {
|
||||
if (lobby.source !== "public") {
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
}
|
||||
const auth = await userAuth();
|
||||
const playerRole = auth !== false ? (auth.claims.role ?? null) : null;
|
||||
const newLobbyHandle = joinLobby(this.eventBus, {
|
||||
gameID: lobby.gameID,
|
||||
serverConfig: config,
|
||||
@@ -763,6 +765,7 @@ class Client {
|
||||
turnstileToken: await this.getTurnstileToken(lobby),
|
||||
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
|
||||
playerClanTag: this.usernameInput?.getClanTag() ?? null,
|
||||
playerRole,
|
||||
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
|
||||
gameRecord: lobby.gameRecord,
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export function createRenderer(
|
||||
canvas: HTMLCanvasElement,
|
||||
game: GameView,
|
||||
eventBus: EventBus,
|
||||
playerRole: string | null,
|
||||
): GameRenderer {
|
||||
const transformHandler = new TransformHandler(game, eventBus, canvas);
|
||||
const userSettings = new UserSettings();
|
||||
@@ -204,6 +205,8 @@ export function createRenderer(
|
||||
playerPanel.emojiTable = emojiTable;
|
||||
playerPanel.uiState = uiState;
|
||||
|
||||
playerPanel.setRole(playerRole);
|
||||
|
||||
const chatModal = document.querySelector("chat-modal") as ChatModal;
|
||||
if (!(chatModal instanceof ChatModal)) {
|
||||
console.error("chat modal not found");
|
||||
|
||||
@@ -18,6 +18,7 @@ export class PlayerModerationModal extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) open: boolean = false;
|
||||
@property({ type: Boolean }) alreadyKicked: boolean = false;
|
||||
@property({ type: Boolean }) isAdmin: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -44,7 +45,7 @@ export class PlayerModerationModal extends LitElement {
|
||||
|
||||
private canKick(my: PlayerView, other: PlayerView): boolean {
|
||||
return (
|
||||
my.isLobbyCreator() &&
|
||||
(my.isLobbyCreator() || this.isAdmin) &&
|
||||
other !== my &&
|
||||
other.type() === PlayerType.Human &&
|
||||
!!other.clientID()
|
||||
|
||||
@@ -72,6 +72,15 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
@state() private otherProfile: PlayerProfile | null = null;
|
||||
@state() private suppressNextHide: boolean = false;
|
||||
@state() private moderationTarget: PlayerView | null = null;
|
||||
@state() private playerRole: string | null = null;
|
||||
|
||||
setRole(role: string | null): void {
|
||||
this.playerRole = role;
|
||||
}
|
||||
|
||||
private get isAdminRole(): boolean {
|
||||
return this.playerRole === "admin" || this.playerRole === "root";
|
||||
}
|
||||
|
||||
private ctModal: ChatModal;
|
||||
|
||||
@@ -441,8 +450,12 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderModeration(my: PlayerView, other: PlayerView) {
|
||||
if (!my.isLobbyCreator()) return html``;
|
||||
private renderModeration(
|
||||
my: PlayerView,
|
||||
other: PlayerView,
|
||||
isAdmin: boolean,
|
||||
) {
|
||||
if (!my.isLobbyCreator() && !isAdmin) return html``;
|
||||
const moderationTitle = translateText("player_panel.moderation");
|
||||
|
||||
return html`
|
||||
@@ -845,7 +858,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
})}
|
||||
</div>`
|
||||
: ""}
|
||||
${this.renderModeration(my, other)}
|
||||
${this.renderModeration(my, other, this.isAdminRole)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -963,6 +976,7 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
.myPlayer=${my}
|
||||
.target=${this.moderationTarget}
|
||||
.eventBus=${this.eventBus}
|
||||
.isAdmin=${this.isAdminRole}
|
||||
.alreadyKicked=${this.kickedPlayerIDs.has(
|
||||
String(this.moderationTarget.id()),
|
||||
)}
|
||||
|
||||
@@ -51,6 +51,11 @@ export const TokenPayloadSchema = z.object({
|
||||
});
|
||||
export type TokenPayload = z.infer<typeof TokenPayloadSchema>;
|
||||
|
||||
export const ADMIN_ROLES = ["admin", "root"] as const;
|
||||
export function isAdminRole(role: string | null | undefined): boolean {
|
||||
return role === "admin" || role === "root";
|
||||
}
|
||||
|
||||
export const DiscordUserSchema = z.object({
|
||||
id: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
@@ -72,7 +77,6 @@ export const UserMeResponseSchema = z.object({
|
||||
}),
|
||||
player: z.object({
|
||||
publicId: z.string(),
|
||||
roles: z.string().array().optional(),
|
||||
flares: z.string().array().optional(),
|
||||
achievements: z.object({
|
||||
singleplayerMap: z.array(SingleplayerMapAchievementSchema),
|
||||
|
||||
@@ -14,7 +14,7 @@ export class Client {
|
||||
public readonly clientID: ClientID,
|
||||
public readonly persistentID: string,
|
||||
public readonly claims: TokenPayload | null,
|
||||
public readonly roles: string[] | undefined,
|
||||
public readonly role: string | null,
|
||||
public readonly flares: string[] | undefined,
|
||||
public readonly ip: string,
|
||||
public username: string,
|
||||
|
||||
+23
-12
@@ -2,6 +2,7 @@ import ipAnonymize from "ip-anonymize";
|
||||
import { Logger } from "winston";
|
||||
import WebSocket from "ws";
|
||||
import { z } from "zod";
|
||||
import { isAdminRole } from "../core/ApiSchemas";
|
||||
import { GameEnv, ServerConfig } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ export enum GamePhase {
|
||||
|
||||
const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session";
|
||||
const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator";
|
||||
const KICK_REASON_ADMIN = "kick_reason.admin";
|
||||
const KICK_REASON_HOST_LEFT = "kick_reason.host_left";
|
||||
const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data";
|
||||
const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message";
|
||||
@@ -394,18 +396,24 @@ export class GameServer {
|
||||
|
||||
// Handle kick_player intent via WebSocket
|
||||
case "kick_player": {
|
||||
// Check if the authenticated client is the lobby creator
|
||||
if (client.clientID !== this.lobbyCreatorID) {
|
||||
this.log.warn(`Only lobby creator can kick players`, {
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
target: stampedIntent.target,
|
||||
gameID: this.id,
|
||||
});
|
||||
const isLobbyCreator = client.clientID === this.lobbyCreatorID;
|
||||
const isAdmin = isAdminRole(client.role);
|
||||
|
||||
// Check if the authenticated client is the lobby creator or admin
|
||||
if (!isLobbyCreator && !isAdmin) {
|
||||
this.log.warn(
|
||||
`Only lobby creator or admin can kick players`,
|
||||
{
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
target: stampedIntent.target,
|
||||
gameID: this.id,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow lobby creator to kick themselves
|
||||
// Don't allow kicking yourself
|
||||
if (client.clientID === stampedIntent.target) {
|
||||
this.log.warn(`Cannot kick yourself`, {
|
||||
clientID: client.clientID,
|
||||
@@ -414,8 +422,9 @@ export class GameServer {
|
||||
}
|
||||
|
||||
// Log and execute the kick
|
||||
this.log.info(`Lobby creator initiated kick of player`, {
|
||||
creatorID: client.clientID,
|
||||
this.log.info(`Player initiated kick`, {
|
||||
kickerID: client.clientID,
|
||||
isAdmin,
|
||||
target: stampedIntent.target,
|
||||
gameID: this.id,
|
||||
kickMethod: "websocket",
|
||||
@@ -423,7 +432,9 @@ export class GameServer {
|
||||
|
||||
this.kickClient(
|
||||
stampedIntent.target,
|
||||
KICK_REASON_LOBBY_CREATOR,
|
||||
isAdmin && !isLobbyCreator
|
||||
? KICK_REASON_ADMIN
|
||||
: KICK_REASON_LOBBY_CREATOR,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,7 +401,6 @@ export async function startWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
let roles: string[] | undefined;
|
||||
let flares: string[] | undefined;
|
||||
|
||||
const allowedFlares = config.allowedFlares();
|
||||
@@ -422,7 +421,6 @@ export async function startWorker() {
|
||||
ws.close(1002, "Unauthorized: user me fetch failed");
|
||||
return;
|
||||
}
|
||||
roles = result.response.player.roles;
|
||||
flares = result.response.player.flares;
|
||||
|
||||
if (allowedFlares !== undefined) {
|
||||
@@ -484,7 +482,7 @@ export async function startWorker() {
|
||||
generateID(),
|
||||
persistentId,
|
||||
claims,
|
||||
roles,
|
||||
claims?.role ?? null,
|
||||
flares,
|
||||
ip,
|
||||
censoredUsername,
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("PlayerPanel - kick player moderation", () => {
|
||||
} as unknown as PlayerView;
|
||||
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
(panel as any).renderModeration(my, other);
|
||||
(panel as any).renderModeration(my, other, false);
|
||||
expect(actionButton).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0],
|
||||
@@ -71,16 +71,31 @@ describe("PlayerPanel - kick player moderation", () => {
|
||||
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
(panel as any).kickedPlayerIDs.add("2");
|
||||
(panel as any).renderModeration(my, other);
|
||||
(panel as any).renderModeration(my, other, false);
|
||||
expect(actionButton).toHaveBeenCalledTimes(1);
|
||||
|
||||
const notCreator = { isLobbyCreator: () => false } as unknown as PlayerView;
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
(panel as any).kickedPlayerIDs.clear();
|
||||
(panel as any).renderModeration(notCreator, other);
|
||||
(panel as any).renderModeration(notCreator, other, false);
|
||||
expect(actionButton).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders moderation action when isAdmin=true even if not lobby creator", () => {
|
||||
const notCreator = { isLobbyCreator: () => false } as unknown as PlayerView;
|
||||
const other = {
|
||||
id: () => 2,
|
||||
name: () => "Other",
|
||||
displayName: () => "[TAG] Other",
|
||||
type: () => PlayerType.Human,
|
||||
clientID: () => "client-2",
|
||||
} as unknown as PlayerView;
|
||||
|
||||
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
|
||||
(panel as any).renderModeration(notCreator, other, true);
|
||||
expect(actionButton).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("opens moderation modal and hides after a kick", () => {
|
||||
const other = {
|
||||
id: () => 2,
|
||||
@@ -171,4 +186,40 @@ describe("PlayerModerationModal - kick confirmation", () => {
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
expect(kickedListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("canKick", () => {
|
||||
function makeModal(isAdmin: boolean) {
|
||||
const modal = new PlayerModerationModal();
|
||||
modal.isAdmin = isAdmin;
|
||||
return modal;
|
||||
}
|
||||
|
||||
const nonCreator = { isLobbyCreator: () => false } as unknown as PlayerView;
|
||||
const creator = { isLobbyCreator: () => true } as unknown as PlayerView;
|
||||
const humanOther = {
|
||||
type: () => PlayerType.Human,
|
||||
clientID: () => "client-other",
|
||||
} as unknown as PlayerView;
|
||||
|
||||
test("admin non-creator can kick a valid other player", () => {
|
||||
const modal = makeModal(true);
|
||||
expect((modal as any).canKick(nonCreator, humanOther)).toBe(true);
|
||||
});
|
||||
|
||||
test("non-admin non-creator cannot kick", () => {
|
||||
const modal = makeModal(false);
|
||||
expect((modal as any).canKick(nonCreator, humanOther)).toBe(false);
|
||||
});
|
||||
|
||||
test("admin cannot kick themselves", () => {
|
||||
const modal = makeModal(true);
|
||||
// same object reference → other === my
|
||||
expect((modal as any).canKick(nonCreator, nonCreator)).toBe(false);
|
||||
});
|
||||
|
||||
test("lobby creator can kick a valid other player", () => {
|
||||
const modal = makeModal(false);
|
||||
expect((modal as any).canKick(creator, humanOther)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/core/configuration/ConfigLoader", () => ({
|
||||
getServerConfigFromServer: () => ({
|
||||
otelEnabled: () => false,
|
||||
otelAuthHeader: () => "",
|
||||
otelEndpoint: () => "",
|
||||
env: () => 0, // GameEnv.Dev
|
||||
}),
|
||||
getServerConfig: () => ({
|
||||
otelEnabled: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/core/Schemas", async () => {
|
||||
const actual = (await vi.importActual("../../src/core/Schemas")) as any;
|
||||
return {
|
||||
...actual,
|
||||
GameStartInfoSchema: {
|
||||
safeParse: (data: any) => ({ success: true, data: data }),
|
||||
},
|
||||
ServerPrestartMessageSchema: {
|
||||
safeParse: (data: any) => ({ success: true, data: data }),
|
||||
},
|
||||
ClientMessageSchema: {
|
||||
safeParse: (data: any) => ({ success: true, data: data }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { GameType } from "../../src/core/game/Game";
|
||||
import { Client } from "../../src/server/Client";
|
||||
import { GameServer } from "../../src/server/GameServer";
|
||||
|
||||
function makeMockWs() {
|
||||
const handlers: Record<string, (...args: any[]) => any> = {};
|
||||
return {
|
||||
on: (event: string, handler: (...args: any[]) => any) => {
|
||||
handlers[event] = handler;
|
||||
},
|
||||
removeAllListeners: (_event: string) => {},
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
readyState: 1,
|
||||
trigger: (event: string, ...args: any[]) => handlers[event]?.(...args),
|
||||
};
|
||||
}
|
||||
|
||||
function makeClient(
|
||||
clientID: string,
|
||||
persistentID: string,
|
||||
role?: string,
|
||||
): { client: Client; ws: ReturnType<typeof makeMockWs> } {
|
||||
const ws = makeMockWs();
|
||||
const client = new Client(
|
||||
clientID,
|
||||
persistentID,
|
||||
null,
|
||||
role ?? null,
|
||||
undefined,
|
||||
"127.0.0.1",
|
||||
"TestUser",
|
||||
null,
|
||||
ws as any,
|
||||
undefined,
|
||||
);
|
||||
return { client, ws };
|
||||
}
|
||||
|
||||
describe("GameServer - kick_player authorization", () => {
|
||||
let mockLogger: any;
|
||||
let mockConfig: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockLogger = {
|
||||
child: vi.fn().mockReturnThis(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
mockConfig = {
|
||||
turnIntervalMs: () => 100,
|
||||
gameCreationRate: () => 1000,
|
||||
env: () => 0,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
function makeGame(creatorPersistentID?: string) {
|
||||
return new GameServer(
|
||||
"test-game",
|
||||
mockLogger,
|
||||
Date.now(),
|
||||
mockConfig,
|
||||
{ gameType: GameType.Private } as any,
|
||||
creatorPersistentID,
|
||||
);
|
||||
}
|
||||
|
||||
async function sendKickMessage(
|
||||
ws: ReturnType<typeof makeMockWs>,
|
||||
target: string,
|
||||
) {
|
||||
await ws.trigger(
|
||||
"message",
|
||||
JSON.stringify({
|
||||
type: "intent",
|
||||
intent: { type: "kick_player", target },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("lobby creator can kick another player with lobby_creator reason", async () => {
|
||||
const game = makeGame("creator-pid");
|
||||
const kickSpy = vi.spyOn(game, "kickClient");
|
||||
|
||||
const { client: creator, ws: creatorWs } = makeClient(
|
||||
"creator-cid",
|
||||
"creator-pid",
|
||||
);
|
||||
const { client: target } = makeClient("target-cid", "target-pid");
|
||||
|
||||
game.joinClient(creator);
|
||||
game.joinClient(target);
|
||||
|
||||
await sendKickMessage(creatorWs, "target-cid");
|
||||
|
||||
expect(kickSpy).toHaveBeenCalledOnce();
|
||||
expect(kickSpy).toHaveBeenCalledWith(
|
||||
"target-cid",
|
||||
"kick_reason.lobby_creator",
|
||||
);
|
||||
});
|
||||
|
||||
it("admin-flared player can kick another player with admin reason", async () => {
|
||||
const game = makeGame();
|
||||
const kickSpy = vi.spyOn(game, "kickClient");
|
||||
|
||||
const { client: admin, ws: adminWs } = makeClient(
|
||||
"admin-cid",
|
||||
"admin-pid",
|
||||
"admin",
|
||||
);
|
||||
const { client: target } = makeClient("target-cid", "target-pid");
|
||||
|
||||
game.joinClient(admin);
|
||||
game.joinClient(target);
|
||||
|
||||
await sendKickMessage(adminWs, "target-cid");
|
||||
|
||||
expect(kickSpy).toHaveBeenCalledOnce();
|
||||
expect(kickSpy).toHaveBeenCalledWith("target-cid", "kick_reason.admin");
|
||||
});
|
||||
|
||||
it("non-creator non-admin cannot kick", async () => {
|
||||
const game = makeGame("creator-pid");
|
||||
const kickSpy = vi.spyOn(game, "kickClient");
|
||||
|
||||
const { client: creator } = makeClient("creator-cid", "creator-pid");
|
||||
const { client: rando, ws: randoWs } = makeClient("rando-cid", "rando-pid");
|
||||
const { client: target } = makeClient("target-cid", "target-pid");
|
||||
|
||||
game.joinClient(creator);
|
||||
game.joinClient(rando);
|
||||
game.joinClient(target);
|
||||
|
||||
await sendKickMessage(randoWs, "target-cid");
|
||||
|
||||
expect(kickSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cannot kick yourself even as lobby creator", async () => {
|
||||
const game = makeGame("creator-pid");
|
||||
const kickSpy = vi.spyOn(game, "kickClient");
|
||||
|
||||
const { client: creator, ws: creatorWs } = makeClient(
|
||||
"creator-cid",
|
||||
"creator-pid",
|
||||
);
|
||||
game.joinClient(creator);
|
||||
|
||||
await sendKickMessage(creatorWs, "creator-cid");
|
||||
|
||||
expect(kickSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user