mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-25 04:26:00 -05:00
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
15
migrations/090-tournament-org-banned-players.js
Normal file
15
migrations/090-tournament-org-banned-players.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user