Tournament org bans (#2397)

* Initial

* e2e test

* extract

* finish?
This commit is contained in:
Kalle
2025-06-16 22:28:10 +03:00
committed by GitHub
parent bcd014c352
commit c35da55294
37 changed files with 802 additions and 41 deletions

View File

@@ -13,6 +13,10 @@
margin-inline-end: var(--s-1-5);
}
.tabButton img {
margin-inline-end: var(--s-1-5);
}
.padded .tabPanel {
padding-block-start: var(--s-4);
}

View File

@@ -1,4 +1,3 @@
import * as React from "react";
import {
Controller,
type FieldPath,
@@ -7,7 +6,6 @@ import {
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { UserSearch } from "../elements/UserSearch";
export function UserSearchFormField<T extends FieldValues>({
@@ -16,13 +14,11 @@ export function UserSearchFormField<T extends FieldValues>({
bottomText,
}: { label: string; name: FieldPath<T>; bottomText?: string }) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label htmlFor={id}>{label}</Label>
<Controller
control={methods.control}
name={name}
@@ -32,6 +28,7 @@ export function UserSearchFormField<T extends FieldValues>({
initialUserId={value}
onBlur={onBlur}
ref={ref}
label={label}
/>
)}
/>

View File

@@ -128,6 +128,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
badgesToUsers,
badgeManagers,
patrons,
organization,
calendarEvents,
calendarEventBadges,
calendarEventResults,
@@ -175,7 +176,6 @@ const basicSeeds = (variation?: SeedVariation | null) => [
scrimPostRequests,
associations,
notifications,
organization,
];
export async function seed(variation?: SeedVariation | null) {
@@ -198,6 +198,7 @@ export async function seed(variation?: SeedVariation | null) {
function wipeDB() {
const tablesToDelete = [
"ScrimPost",
"TournamentOrganizationBannedUser",
"Association",
"LFGPost",
"Skill",
@@ -1095,7 +1096,8 @@ function calendarEventWithToTools(
"discordInviteCode",
"bracketUrl",
"authorId",
"tournamentId"
"tournamentId",
"organizationId"
) values (
$id,
$name,
@@ -1103,7 +1105,8 @@ function calendarEventWithToTools(
$discordInviteCode,
$bracketUrl,
$authorId,
$tournamentId
$tournamentId,
$organizationId
)
`,
)
@@ -1115,6 +1118,7 @@ function calendarEventWithToTools(
bracketUrl: faker.internet.url(),
authorId: ADMIN_ID,
tournamentId,
organizationId: event === "PICNIC" ? 1 : null,
});
const halfAnHourFromNow = new Date(Date.now() + 1000 * 60 * 30);

View File

@@ -743,6 +743,13 @@ export interface TournamentBracketProgressionOverride {
tournamentId: number;
}
export interface TournamentOrganizationBannedUser {
organizationId: number;
userId: number;
privateNote: string | null;
updatedAt: Generated<number>;
}
/** Indicates a user trusts another. Allows direct adding to groups/teams without invite links. */
export interface TrustRelationship {
trustGiverUserId: number;
@@ -1110,6 +1117,7 @@ export interface DB {
TournamentOrganizationBadge: TournamentOrganizationBadge;
TournamentOrganizationSeries: TournamentOrganizationSeries;
TournamentBracketProgressionOverride: TournamentBracketProgressionOverride;
TournamentOrganizationBannedUser: TournamentOrganizationBannedUser;
TrustRelationship: TrustRelationship;
UnvalidatedUserSubmittedImage: UnvalidatedUserSubmittedImage;
UnvalidatedVideo: UnvalidatedVideo;

View File

@@ -1,8 +1,8 @@
import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { Tables, TablesInsertable } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { mySlugify, userSubmittedImage } from "~/utils/urls";
import { HACKY_resolvePicture } from "../tournament/tournament-utils";
@@ -98,12 +98,15 @@ export async function findBySlug(slug: string) {
if (!organization) return null;
const orgAdminUserIds = organization.members
.filter((member) => member.role === "ADMIN")
.map((member) => member.id);
return {
...organization,
permissions: {
EDIT: organization.members
.filter((member) => member.role === "ADMIN")
.map((member) => member.id),
EDIT: orgAdminUserIds,
BAN: orgAdminUserIds,
},
};
}
@@ -418,3 +421,73 @@ export function update({
return updatedOrg;
});
}
/**
* Inserts a user to the banned list for a tournament organization or updates the existing entry if already exists.
*/
export function upsertBannedUser(
args: Omit<TablesInsertable["TournamentOrganizationBannedUser"], "updatedAt">,
) {
return db
.insertInto("TournamentOrganizationBannedUser")
.values({ ...args, updatedAt: databaseTimestampNow() })
.execute();
}
/**
* Removes a user from the banned list for a tournament organization
*/
export function unbanUser({
organizationId,
userId,
}: {
organizationId: number;
userId: number;
}) {
return db
.deleteFrom("TournamentOrganizationBannedUser")
.where("organizationId", "=", organizationId)
.where("userId", "=", userId)
.execute();
}
/**
* Returns all banned users for a specific tournament organization
*/
export function allBannedUsersByOrganizationId(organizationId: number) {
return db
.selectFrom("TournamentOrganizationBannedUser")
.innerJoin("User", "User.id", "TournamentOrganizationBannedUser.userId")
.select([
"TournamentOrganizationBannedUser.privateNote",
"TournamentOrganizationBannedUser.updatedAt",
...COMMON_USER_FIELDS,
])
.where(
"TournamentOrganizationBannedUser.organizationId",
"=",
organizationId,
)
.orderBy("TournamentOrganizationBannedUser.updatedAt", "desc")
.execute();
}
/**
* Checks if a user is banned by a specific organization
*/
export async function isUserBannedByOrganization({
organizationId,
userId,
}: {
organizationId: number;
userId: number;
}) {
const result = await db
.selectFrom("TournamentOrganizationBannedUser")
.select("userId")
.where("organizationId", "=", organizationId)
.where("userId", "=", userId)
.executeTakeFirst();
return Boolean(result);
}

View File

@@ -0,0 +1,66 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { logger } from "~/utils/logger";
import { parseRequestPayload } from "~/utils/remix.server";
import { errorToast } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
import { orgPageActionSchema } from "../tournament-organization-schemas";
import { organizationFromParams } from "../tournament-organization-utils.server";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const organization = await organizationFromParams(params);
const data = await parseRequestPayload({
request,
schema: orgPageActionSchema,
});
requirePermission(organization, "BAN", user);
switch (data._action) {
case "BAN_USER": {
const bannedUsers =
await TournamentOrganizationRepository.allBannedUsersByOrganizationId(
organization.id,
);
if (bannedUsers.length >= TOURNAMENT_ORGANIZATION.MAX_BANNED_USERS) {
errorToast(
`Organization cannot ban more than ${TOURNAMENT_ORGANIZATION.MAX_BANNED_USERS} users`,
);
}
await TournamentOrganizationRepository.upsertBannedUser({
organizationId: organization.id,
userId: data.userId,
privateNote: data.privateNote,
});
logger.info(
`User banned: organization=${organization.name} (${organization.id}), userId=${data.userId}, banned by userId=${user.id}`,
);
break;
}
case "UNBAN_USER": {
await TournamentOrganizationRepository.unbanUser({
organizationId: organization.id,
userId: data.userId,
});
logger.info(
`User unbanned: organization=${organization.name} (${organization.id}), userId=${data.userId}, unbanned by userId=${user.id}`,
);
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};

View File

@@ -0,0 +1,48 @@
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { UserSearchFormField } from "~/components/form/UserSearchFormField";
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
import { banUserActionSchema } from "../tournament-organization-schemas";
type FormFields = z.infer<typeof banUserActionSchema>;
export function BanUserModal() {
const { t } = useTranslation(["org", "common"]);
return (
<SendouDialog
heading={t("org:banned.banModal.title")}
trigger={
<SendouButton variant="outlined" size="small">
{t("org:banned.ban")}
</SendouButton>
}
showCloseButton
>
<SendouForm
schema={banUserActionSchema}
defaultValues={{
_action: "BAN_USER",
userId: undefined,
privateNote: null,
}}
>
<UserSearchFormField<FormFields>
label={t("org:banned.banModal.player")}
name="userId"
/>
<TextAreaFormField<FormFields>
label={t("org:banned.banModal.note")}
name="privateNote"
maxLength={TOURNAMENT_ORGANIZATION.BAN_REASON_MAX_LENGTH}
bottomText={t("org:banned.banModal.noteHelp")}
/>
</SendouForm>
</SendouDialog>
);
}

View File

@@ -0,0 +1,20 @@
.bannedUsersContainer {
width: 100%;
}
.actionsCell {
text-align: center;
width: 100px;
}
.reasonCell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.banPlayerButton {
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,100 @@
import { Link } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Table } from "~/components/Table";
import { SendouButton } from "~/components/elements/Button";
import { BanUserModal } from "~/features/tournament-organization/components/BanUserModal";
import type { OrganizationPageLoaderData } from "~/features/tournament-organization/loaders/org.$slug.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { userPage } from "~/utils/urls";
import styles from "../components/BannedPlayersList.module.css";
export function BannedUsersList({
bannedUsers,
}: { bannedUsers: NonNullable<OrganizationPageLoaderData["bannedUsers"]> }) {
const { t, i18n } = useTranslation(["org"]);
const bannedUsersKey = (bannedUsers ?? [])
.map((u) => [u.id, u.privateNote].join("-"))
.join(",");
if (bannedUsers.length === 0) {
return (
<div className="stack lg">
<div className="text-sm">{t("org:banned.empty")}</div>
<div className={styles.banPlayerButton}>
<BanUserModal key={bannedUsersKey} />
</div>
</div>
);
}
return (
<div className="stack lg">
<div className="text-sm">{t("org:banned.description")}</div>
<div className={styles.bannedUsersContainer}>
<Table>
<thead>
<tr>
<th>{t("org:banned.player")}</th>
<th>{t("org:banned.note")}</th>
<th>{t("org:banned.date")}</th>
<th>{t("org:banned.actions")}</th>
</tr>
</thead>
<tbody>
{bannedUsers.map((bannedUser) => (
<tr key={bannedUser.id}>
<td>
<Link
to={userPage(bannedUser)}
className="stack horizontal xs items-center w-max"
>
<Avatar user={bannedUser} size="xs" />
{bannedUser.username}
</Link>
</td>
<td
className={clsx("text-sm text-lighter", styles.reasonCell)}
title={bannedUser.privateNote ?? undefined}
>
{bannedUser.privateNote ?? "-"}
</td>
<td className="text-sm text-lighter whitespace-nowrap">
{databaseTimestampToDate(
bannedUser.updatedAt,
).toLocaleDateString(i18n.language, {
day: "numeric",
month: "short",
year: "numeric",
})}
</td>
<td className={styles.actionsCell}>
<FormWithConfirm
fields={[
["_action", "UNBAN_USER"],
["userId", bannedUser.id],
]}
dialogHeading={t("org:banned.unbanConfirm", {
username: bannedUser.username,
})}
submitButtonText={t("org:banned.unban")}
>
<SendouButton variant="minimal-destructive" size="small">
{t("org:banned.unban")}
</SendouButton>
</FormWithConfirm>
</td>
</tr>
))}
</tbody>
</Table>
</div>
<div className={styles.banPlayerButton}>
<BanUserModal key={bannedUsersKey} />
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod/v4";
import { getUser } from "~/features/auth/core/user.server";
import type { SerializeFrom } from "~/utils/remix";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
@@ -8,6 +9,8 @@ import { eventLeaderboards } from "../core/leaderboards.server";
import { TOURNAMENT_SERIES_LEADERBOARD_SIZE } from "../tournament-organization-constants";
import { organizationFromParams } from "../tournament-organization-utils.server";
export type OrganizationPageLoaderData = SerializeFrom<typeof loader>;
export async function loader({ params, request }: LoaderFunctionArgs) {
const user = await getUser(request);
const {
@@ -73,6 +76,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
series: await seriesInfo(),
month,
year,
bannedUsers:
user?.id && organization.permissions.BAN.includes(user.id)
? await TournamentOrganizationRepository.allBannedUsersByOrganizationId(
organization.id,
)
: null,
};
}

View File

@@ -3,6 +3,7 @@ import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
@@ -14,7 +15,11 @@ import {
SendouTabs,
} from "~/components/elements/Tabs";
import { EditIcon } from "~/components/icons/Edit";
import { LinkIcon } from "~/components/icons/Link";
import { LockIcon } from "~/components/icons/Lock";
import { UsersIcon } from "~/components/icons/Users";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
import { useHasPermission } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
@@ -22,6 +27,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import {
BLANK_IMAGE_URL,
calendarEventPage,
navIconUrl,
tournamentOrganizationEditPage,
tournamentOrganizationPage,
tournamentPage,
@@ -32,8 +38,9 @@ import { EventCalendar } from "../components/EventCalendar";
import { SocialLinksList } from "../components/SocialLinksList";
import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "../tournament-organization-constants";
import { action } from "../actions/org.$slug.server";
import { loader } from "../loaders/org.$slug.server";
export { loader };
export { action, loader };
import "../tournament-organization.css";
@@ -142,6 +149,7 @@ function LogoHeader() {
function InfoTabs() {
const { t } = useTranslation(["org"]);
const data = useLoaderData<typeof loader>();
const canBanPlayers = useHasPermission(data.organization, "BAN");
const hasSocials =
data.organization.socials && data.organization.socials.length > 0;
@@ -151,13 +159,28 @@ function InfoTabs() {
<div>
<SendouTabs>
<SendouTabList>
<SendouTab id="socials" isDisabled={!hasSocials}>
<SendouTab id="socials" isDisabled={!hasSocials} icon={<LinkIcon />}>
{t("org:edit.form.socialLinks.title")}
</SendouTab>
<SendouTab id="members">{t("org:edit.form.members.title")}</SendouTab>
<SendouTab id="badges" isDisabled={!hasBadges}>
<SendouTab id="members" icon={<UsersIcon />}>
{t("org:edit.form.members.title")}
</SendouTab>
<SendouTab
id="badges"
isDisabled={!hasBadges}
icon={<Image path={navIconUrl("badges")} alt="" width={16} />}
>
{t("org:edit.form.badges.title")}
</SendouTab>
{canBanPlayers && data.bannedUsers ? (
<SendouTab
id="banned-users"
icon={<LockIcon />}
data-testid="banned-users-tab"
>
{t("org:banned.title")}
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="socials">
<SocialLinksList links={data.organization.socials ?? []} />
@@ -168,6 +191,11 @@ function InfoTabs() {
<SendouTabPanel id="badges">
<BadgeDisplay badges={data.organization.badges} />
</SendouTabPanel>
{data.bannedUsers ? (
<SendouTabPanel id="banned-users">
<BannedUsersList bannedUsers={data.bannedUsers} />
</SendouTabPanel>
) : null}
</SendouTabs>
</div>
);

View File

@@ -3,4 +3,6 @@ export const TOURNAMENT_SERIES_LEADERBOARD_SIZE = 50;
export const TOURNAMENT_ORGANIZATION = {
DESCRIPTION_MAX_LENGTH: 1_000,
BAN_REASON_MAX_LENGTH: 200,
MAX_BANNED_USERS: 100,
};

View File

@@ -1,9 +1,14 @@
import { z } from "zod/v4";
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
import { TOURNAMENT_ORGANIZATION } from "~/features/tournament-organization/tournament-organization-constants";
import { mySlugify } from "~/utils/urls";
import { falsyToNull, id } from "~/utils/zod";
import {
_action,
falsyToNull,
id,
safeNullableStringSchema,
} from "~/utils/zod";
export const DESCRIPTION_MAX_LENGTH = 1_000;
export const organizationEditSchema = z.object({
name: z
.string()
@@ -15,7 +20,11 @@ export const organizationEditSchema = z.object({
}),
description: z.preprocess(
falsyToNull,
z.string().trim().max(DESCRIPTION_MAX_LENGTH).nullable(),
z
.string()
.trim()
.max(TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH)
.nullable(),
),
members: z
.array(
@@ -58,7 +67,11 @@ export const organizationEditSchema = z.object({
name: z.string().trim().min(1).max(32),
description: z.preprocess(
falsyToNull,
z.string().trim().max(DESCRIPTION_MAX_LENGTH).nullable(),
z
.string()
.trim()
.max(TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH)
.nullable(),
),
showLeaderboard: z.boolean(),
}),
@@ -73,3 +86,21 @@ export const organizationEditSchema = z.object({
),
badges: z.array(id).max(50),
});
export const banUserActionSchema = z.object({
_action: _action("BAN_USER"),
userId: id,
privateNote: safeNullableStringSchema({
max: TOURNAMENT_ORGANIZATION.BAN_REASON_MAX_LENGTH,
}),
});
export const unbanUserActionSchema = z.object({
_action: _action("UNBAN_USER"),
userId: id,
});
export const orgPageActionSchema = z.union([
banUserActionSchema,
unbanUserActionSchema,
]);

View File

@@ -21,7 +21,10 @@ import { giveTrust } from "../queries/giveTrust.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { joinSchema } from "../tournament-schemas.server";
import { validateCanJoinTeam } from "../tournament-utils";
import { inGameNameIfNeeded } from "../tournament-utils.server";
import {
inGameNameIfNeeded,
requireNotBannedByOrganization,
} from "../tournament-utils.server";
export const action: ActionFunction = async ({ request, params }) => {
const { id: tournamentId } = parseParams({
@@ -38,6 +41,11 @@ export const action: ActionFunction = async ({ request, params }) => {
const tournament = await tournamentFromDB({ tournamentId, user });
await requireNotBannedByOrganization({
tournament,
user,
});
const teamToJoin = tournament.ctx.teams.find(
(team) => team.id === leanTeam.id,
);

View File

@@ -32,7 +32,10 @@ import {
isOneModeTournamentOf,
validateCounterPickMapPool,
} from "../tournament-utils";
import { inGameNameIfNeeded } from "../tournament-utils.server";
import {
inGameNameIfNeeded,
requireNotBannedByOrganization,
} from "../tournament-utils.server";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
@@ -93,6 +96,11 @@ export const action: ActionFunction = async ({ request, params }) => {
},
});
} else {
await requireNotBannedByOrganization({
tournament,
user,
});
errorToastIfFalsy(!tournament.isInvitational, "Event is invite only");
errorToastIfFalsy(
(await UserRepository.findLeanById(user.id))?.friendCode,
@@ -258,6 +266,12 @@ export const action: ActionFunction = async ({ request, params }) => {
);
errorToastIfFalsy(tournament.registrationOpen, "Registration is closed");
await requireNotBannedByOrganization({
tournament,
user: { id: data.userId },
message: "The user is banned from events hosted by this organization",
});
joinTeam({
userId: data.userId,
newTeamId: ownTeam.id,

View File

@@ -1,5 +1,6 @@
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { errorToast, errorToastIfFalsy } from "~/utils/remix.server";
import type { Tournament } from "../tournament-bracket/core/Tournament";
export const inGameNameIfNeeded = async ({
@@ -17,3 +18,25 @@ export const inGameNameIfNeeded = async ({
return inGameName;
};
export async function requireNotBannedByOrganization({
tournament,
user,
message = "You are banned from events hosted by this organization",
}: {
tournament: Tournament;
user: { id: number };
message?: string;
}) {
if (!tournament.ctx.organization) return;
const isBanned =
await TournamentOrganizationRepository.isUserBannedByOrganization({
organizationId: tournament.ctx.organization.id,
userId: user.id,
});
if (isBanned) {
errorToast(message);
}
}

View File

@@ -19,12 +19,14 @@ export async function selectUser({
page,
userName,
labelName,
exact = false,
}: {
page: Page;
userName: string;
labelName: string;
exact?: boolean;
}) {
const comboboxButton = page.getByLabel(labelName);
const comboboxButton = page.getByLabel(labelName, { exact });
const searchInput = page.getByTestId("user-search-input");
const option = page.getByTestId("user-search-item").first();

Binary file not shown.

View File

@@ -186,6 +186,9 @@ erDiagram
Badge ||--o{ TournamentOrganizationBadge : badge_of
TournamentOrganization ||--o{ TournamentOrganizationSeries : has_series
TournamentOrganization ||--o{ TournamentOrganizationBannedUser : has_banned
User ||--o{ TournamentOrganizationBannedUser : banned_from
```
## Videos

View File

@@ -6,9 +6,10 @@ import {
isNotVisible,
navigate,
seed,
selectUser,
submit,
} from "~/utils/playwright";
import { tournamentOrganizationPage } from "~/utils/urls";
import { tournamentOrganizationPage, tournamentPage } from "~/utils/urls";
const url = tournamentOrganizationPage({
organizationSlug: "sendouink",
@@ -49,4 +50,70 @@ test.describe("Tournament Organization", () => {
page.getByText("Editing tournament organization"),
).toBeVisible();
});
test("banned player cannot join a tournament of that organization", async ({
page,
}) => {
await seed(page, "REG_OPEN");
// 1. As admin, ban NZAP user from the organization
await impersonate(page, ADMIN_ID);
await navigate({ page, url });
const bannedUsersTab = page.getByTestId("banned-users-tab");
// Go to banned users section and add NZAP to ban list
await bannedUsersTab.click();
await page.getByText("New ban", { exact: true }).click();
await selectUser({
page,
userName: "N-ZAP",
labelName: "Player",
exact: true,
});
await page.getByLabel("Private note").fill("Test reason");
await submit(page);
// The added ban should be visible in the table
await expect(page.getByRole("table")).toContainText("Test reason");
// 2. As the banned user, try to join a tournament
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: tournamentPage(1),
});
// Try to create a team
await page.getByRole("tab", { name: "Register" }).click();
// Fill in team details
await page.getByLabel("Team name").fill("Banned Team");
await page.getByRole("button", { name: "Save" }).click();
// Verify error toast appears indicating user is banned
await expect(page.getByText(/you are banned/i)).toBeVisible();
// 3. As admin, remove the ban
await impersonate(page, ADMIN_ID);
await navigate({ page, url });
await bannedUsersTab.click();
await page.getByRole("button", { name: "Unban" }).click();
await page.getByTestId("confirm-button").click();
// 4. As the unbanned user, verify they can now join a tournament
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: tournamentPage(1),
});
await page.getByRole("tab", { name: "Register" }).click();
// Try to create a team again
await expect(page.getByText("Teams (0)")).toBeVisible();
await page.getByLabel("Team name").fill("Unbanned Team");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Teams (1)")).toBeVisible();
});
});

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,19 @@
"edit.form.series.seriesName.title": "Series name",
"edit.form.series.showLeaderboard.title": "Show leaderboard",
"edit.form.badges.title": "Badges",
"edit.form.errors.noUnadmin": "Can't remove yourself as an admin"
"edit.form.errors.noUnadmin": "Can't remove yourself as an admin",
"banned.title": "Banned players",
"banned.empty": "No players are currently banned from this organization.",
"banned.description": "Players who are banned cannot create or join teams in tournaments organized by this organization. This tab is only accessible to the admins of this organization.",
"banned.player": "Player",
"banned.note": "Note",
"banned.date": "Banned on",
"banned.actions": "Actions",
"banned.unban": "Unban",
"banned.unbanConfirm": "Are you sure you want to unban {{username}}?",
"banned.ban": "New ban",
"banned.banModal.title": "Banning a player",
"banned.banModal.player": "Player",
"banned.banModal.note": "Private note",
"banned.banModal.noteHelp": "This note is only visible to organization admins."
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "Nombre de la serie",
"edit.form.series.showLeaderboard.title": "Mostrar tablas de posición",
"edit.form.badges.title": "Insignias",
"edit.form.errors.noUnadmin": "No se puede eliminar como admin"
"edit.form.errors.noUnadmin": "No se puede eliminar como admin",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "Nombre de la serie",
"edit.form.series.showLeaderboard.title": "Mostrar tablas de posición",
"edit.form.badges.title": "Insignias",
"edit.form.errors.noUnadmin": "No se puede eliminar como admin"
"edit.form.errors.noUnadmin": "No se puede eliminar como admin",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "Nom de la série ",
"edit.form.series.showLeaderboard.title": "Montrer le leaderboard",
"edit.form.badges.title": "Badges",
"edit.form.errors.noUnadmin": "Vous ne pouvez pas vous supprimer en tant qu'administrateur"
"edit.form.errors.noUnadmin": "Vous ne pouvez pas vous supprimer en tant qu'administrateur",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "Nome serie",
"edit.form.series.showLeaderboard.title": "Mostra classifica",
"edit.form.badges.title": "Medaglia",
"edit.form.errors.noUnadmin": "Non puoi rimuoverti dal ruolo di admin"
"edit.form.errors.noUnadmin": "Non puoi rimuoverti dal ruolo di admin",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "",
"edit.form.series.showLeaderboard.title": "",
"edit.form.badges.title": "",
"edit.form.errors.noUnadmin": ""
"edit.form.errors.noUnadmin": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "Название серии",
"edit.form.series.showLeaderboard.title": "Показать таблицу лидеров",
"edit.form.badges.title": "Награды",
"edit.form.errors.noUnadmin": "Невозможно удалить собственную роль администратора"
"edit.form.errors.noUnadmin": "Невозможно удалить собственную роль администратора",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -21,5 +21,20 @@
"edit.form.series.seriesName.title": "系列名称",
"edit.form.series.showLeaderboard.title": "显示排行榜",
"edit.form.badges.title": "徽章",
"edit.form.errors.noUnadmin": "您不能移除自己的管理者身份"
"edit.form.errors.noUnadmin": "您不能移除自己的管理者身份",
"banned.title": "",
"banned.empty": "",
"banned.description": "",
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
"banned.ban": "",
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
}

View File

@@ -0,0 +1,15 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `
create table "TournamentOrganizationBannedUser" (
"organizationId" integer not null references "TournamentOrganization"("id") on delete cascade,
"userId" integer not null references "User"("id") on delete restrict,
"privateNote" text,
"updatedAt" integer default (strftime('%s', 'now')) not null,
unique("organizationId", "userId") on conflict replace
)
`,
).run();
})();
}