diff --git a/app/components/elements/Tabs.module.css b/app/components/elements/Tabs.module.css index 02b604d72..4c7a2b4fc 100644 --- a/app/components/elements/Tabs.module.css +++ b/app/components/elements/Tabs.module.css @@ -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); } diff --git a/app/components/form/UserSearchFormField.tsx b/app/components/form/UserSearchFormField.tsx index 1902509b6..2d26e6002 100644 --- a/app/components/form/UserSearchFormField.tsx +++ b/app/components/form/UserSearchFormField.tsx @@ -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({ @@ -16,13 +14,11 @@ export function UserSearchFormField({ bottomText, }: { label: string; name: FieldPath; bottomText?: string }) { const methods = useFormContext(); - const id = React.useId(); const error = get(methods.formState.errors, name); return (
- ({ initialUserId={value} onBlur={onBlur} ref={ref} + label={label} /> )} /> diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index eb1495411..25404012a 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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); diff --git a/app/db/tables.ts b/app/db/tables.ts index 07676bf68..d97132883 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -743,6 +743,13 @@ export interface TournamentBracketProgressionOverride { tournamentId: number; } +export interface TournamentOrganizationBannedUser { + organizationId: number; + userId: number; + privateNote: string | null; + updatedAt: Generated; +} + /** 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; diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index f664d0ff0..f1ecd969a 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -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, +) { + 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); +} diff --git a/app/features/tournament-organization/actions/org.$slug.server.ts b/app/features/tournament-organization/actions/org.$slug.server.ts new file mode 100644 index 000000000..ba69aa726 --- /dev/null +++ b/app/features/tournament-organization/actions/org.$slug.server.ts @@ -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; +}; diff --git a/app/features/tournament-organization/components/BanUserModal.tsx b/app/features/tournament-organization/components/BanUserModal.tsx new file mode 100644 index 000000000..4196b8a40 --- /dev/null +++ b/app/features/tournament-organization/components/BanUserModal.tsx @@ -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; + +export function BanUserModal() { + const { t } = useTranslation(["org", "common"]); + + return ( + + {t("org:banned.ban")} + + } + showCloseButton + > + + + label={t("org:banned.banModal.player")} + name="userId" + /> + + + label={t("org:banned.banModal.note")} + name="privateNote" + maxLength={TOURNAMENT_ORGANIZATION.BAN_REASON_MAX_LENGTH} + bottomText={t("org:banned.banModal.noteHelp")} + /> + + + ); +} diff --git a/app/features/tournament-organization/components/BannedPlayersList.module.css b/app/features/tournament-organization/components/BannedPlayersList.module.css new file mode 100644 index 000000000..79c3afe9c --- /dev/null +++ b/app/features/tournament-organization/components/BannedPlayersList.module.css @@ -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; +} diff --git a/app/features/tournament-organization/components/BannedPlayersList.tsx b/app/features/tournament-organization/components/BannedPlayersList.tsx new file mode 100644 index 000000000..29b6f9bcc --- /dev/null +++ b/app/features/tournament-organization/components/BannedPlayersList.tsx @@ -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 }) { + const { t, i18n } = useTranslation(["org"]); + + const bannedUsersKey = (bannedUsers ?? []) + .map((u) => [u.id, u.privateNote].join("-")) + .join(","); + + if (bannedUsers.length === 0) { + return ( +
+
{t("org:banned.empty")}
+
+ +
+
+ ); + } + + return ( +
+
{t("org:banned.description")}
+
+ + + + + + + + + + + {bannedUsers.map((bannedUser) => ( + + + + + + + ))} + +
{t("org:banned.player")}{t("org:banned.note")}{t("org:banned.date")}{t("org:banned.actions")}
+ + + {bannedUser.username} + + + {bannedUser.privateNote ?? "-"} + + {databaseTimestampToDate( + bannedUser.updatedAt, + ).toLocaleDateString(i18n.language, { + day: "numeric", + month: "short", + year: "numeric", + })} + + + + {t("org:banned.unban")} + + +
+
+
+ +
+
+ ); +} diff --git a/app/features/tournament-organization/loaders/org.$slug.server.ts b/app/features/tournament-organization/loaders/org.$slug.server.ts index 7de4ce906..f1f292709 100644 --- a/app/features/tournament-organization/loaders/org.$slug.server.ts +++ b/app/features/tournament-organization/loaders/org.$slug.server.ts @@ -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; + 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, }; } diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx index 21b8a1ff0..c7be75d77 100644 --- a/app/features/tournament-organization/routes/org.$slug.tsx +++ b/app/features/tournament-organization/routes/org.$slug.tsx @@ -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(); + const canBanPlayers = useHasPermission(data.organization, "BAN"); const hasSocials = data.organization.socials && data.organization.socials.length > 0; @@ -151,13 +159,28 @@ function InfoTabs() {
- + }> {t("org:edit.form.socialLinks.title")} - {t("org:edit.form.members.title")} - + }> + {t("org:edit.form.members.title")} + + } + > {t("org:edit.form.badges.title")} + {canBanPlayers && data.bannedUsers ? ( + } + data-testid="banned-users-tab" + > + {t("org:banned.title")} + + ) : null} @@ -168,6 +191,11 @@ function InfoTabs() { + {data.bannedUsers ? ( + + + + ) : null}
); diff --git a/app/features/tournament-organization/tournament-organization-constants.ts b/app/features/tournament-organization/tournament-organization-constants.ts index e43319d21..a9e5f03ad 100644 --- a/app/features/tournament-organization/tournament-organization-constants.ts +++ b/app/features/tournament-organization/tournament-organization-constants.ts @@ -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, }; diff --git a/app/features/tournament-organization/tournament-organization-schemas.ts b/app/features/tournament-organization/tournament-organization-schemas.ts index e97fd4605..f8f9de0c8 100644 --- a/app/features/tournament-organization/tournament-organization-schemas.ts +++ b/app/features/tournament-organization/tournament-organization-schemas.ts @@ -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, +]); diff --git a/app/features/tournament/actions/to.$id.join.server.ts b/app/features/tournament/actions/to.$id.join.server.ts index ebbe68d5f..c8a9fdfa1 100644 --- a/app/features/tournament/actions/to.$id.join.server.ts +++ b/app/features/tournament/actions/to.$id.join.server.ts @@ -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, ); diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 5095e30aa..24d760989 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -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, diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts index 26b4b86b5..0a7095a40 100644 --- a/app/features/tournament/tournament-utils.server.ts +++ b/app/features/tournament/tournament-utils.server.ts @@ -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); + } +} diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts index 82f595d18..3282f76cc 100644 --- a/app/utils/playwright.ts +++ b/app/utils/playwright.ts @@ -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(); diff --git a/db-test.sqlite3 b/db-test.sqlite3 index d5f7c89ca..291f1e492 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/docs/dev/database-relations.md b/docs/dev/database-relations.md index 0bbcab144..1119f94bd 100644 --- a/docs/dev/database-relations.md +++ b/docs/dev/database-relations.md @@ -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 diff --git a/e2e/org.spec.ts b/e2e/org.spec.ts index 81b44b4b2..822bd42bd 100644 --- a/e2e/org.spec.ts +++ b/e2e/org.spec.ts @@ -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(); + }); }); diff --git a/locales/da/org.json b/locales/da/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/da/org.json +++ b/locales/da/org.json @@ -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": "" } diff --git a/locales/de/org.json b/locales/de/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/de/org.json +++ b/locales/de/org.json @@ -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": "" } diff --git a/locales/en/org.json b/locales/en/org.json index a5ff1e8d6..55f525c48 100644 --- a/locales/en/org.json +++ b/locales/en/org.json @@ -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." } diff --git a/locales/es-ES/org.json b/locales/es-ES/org.json index f34fa5bcb..64d5e79ce 100644 --- a/locales/es-ES/org.json +++ b/locales/es-ES/org.json @@ -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": "" } diff --git a/locales/es-US/org.json b/locales/es-US/org.json index f34fa5bcb..64d5e79ce 100644 --- a/locales/es-US/org.json +++ b/locales/es-US/org.json @@ -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": "" } diff --git a/locales/fr-CA/org.json b/locales/fr-CA/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/fr-CA/org.json +++ b/locales/fr-CA/org.json @@ -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": "" } diff --git a/locales/fr-EU/org.json b/locales/fr-EU/org.json index eb25a1140..79bf8a845 100644 --- a/locales/fr-EU/org.json +++ b/locales/fr-EU/org.json @@ -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": "" } diff --git a/locales/he/org.json b/locales/he/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/he/org.json +++ b/locales/he/org.json @@ -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": "" } diff --git a/locales/it/org.json b/locales/it/org.json index 15072ce66..06b165e19 100644 --- a/locales/it/org.json +++ b/locales/it/org.json @@ -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": "" } diff --git a/locales/ja/org.json b/locales/ja/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/ja/org.json +++ b/locales/ja/org.json @@ -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": "" } diff --git a/locales/ko/org.json b/locales/ko/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/ko/org.json +++ b/locales/ko/org.json @@ -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": "" } diff --git a/locales/nl/org.json b/locales/nl/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/nl/org.json +++ b/locales/nl/org.json @@ -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": "" } diff --git a/locales/pl/org.json b/locales/pl/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/pl/org.json +++ b/locales/pl/org.json @@ -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": "" } diff --git a/locales/pt-BR/org.json b/locales/pt-BR/org.json index f7b3b5440..68bdce6e7 100644 --- a/locales/pt-BR/org.json +++ b/locales/pt-BR/org.json @@ -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": "" } diff --git a/locales/ru/org.json b/locales/ru/org.json index cfcab6849..6560ce412 100644 --- a/locales/ru/org.json +++ b/locales/ru/org.json @@ -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": "" } diff --git a/locales/zh/org.json b/locales/zh/org.json index e651b65b7..09f9c1a91 100644 --- a/locales/zh/org.json +++ b/locales/zh/org.json @@ -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": "" } diff --git a/migrations/090-tournament-org-banned-players.js b/migrations/090-tournament-org-banned-players.js new file mode 100644 index 000000000..61eb0c655 --- /dev/null +++ b/migrations/090-tournament-org-banned-players.js @@ -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(); + })(); +}