From dfd84cf8f475fa333dee4dcfbb42f7d3d8bd99c6 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sat, 29 Aug 2026 19:46:06 +0300
Subject: [PATCH] Prevent unreg and kicking members in invitational tournaments
---
.../actions/to.$id.register.server.ts | 8 +++++
.../tournament/routes/to.$id.register.tsx | 33 +++++++------------
.../2026-08-29-invitational-roster-kick.md | 5 +++
.../tournament/tournament-register-page.ts | 2 ++
e2e/tournament-invitational.spec.ts | 7 ++++
5 files changed, 33 insertions(+), 22 deletions(-)
create mode 100644 changelog/2026-08-29-invitational-roster-kick.md
diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts
index a20c9037b..1f220b7a6 100644
--- a/app/features/tournament/actions/to.$id.register.server.ts
+++ b/app/features/tournament/actions/to.$id.register.server.ts
@@ -144,6 +144,10 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "DELETE_TEAM_MEMBER": {
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
+ errorToastIfFalsy(
+ !tournament.isInvitational,
+ "The organizer manages the roster of an invitational team",
+ );
errorToastIfFalsy(
ownTeam.memberUserIds.includes(data.userId),
"User is not in your team",
@@ -344,6 +348,10 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "UNREGISTER": {
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
+ errorToastIfFalsy(
+ !tournament.isInvitational,
+ "The organizer manages the roster of an invitational team",
+ );
errorToastIfFalsy(
!ownTeamCheckedIn,
"You cannot unregister after checking in",
diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx
index 0f5984f0d..9bd30bf26 100644
--- a/app/features/tournament/routes/to.$id.register.tsx
+++ b/app/features/tournament/routes/to.$id.register.tsx
@@ -180,19 +180,12 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
const hasFriendCodeSet = Boolean(user?.friendCode);
- if (!user && !tournament.isInvitational) {
+ if (!user) {
return ;
}
- const showRegistrationProgress = () => {
- if (ownTeam) return true;
-
- return !tournament.isInvitational;
- };
-
const showRegisterNewTeam = () => {
if (ownTeam) return true;
- if (tournament.isInvitational) return false;
if (!tournament.registrationOpen) return false;
return !tournament.regularCheckInHasEnded;
@@ -202,24 +195,19 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
{showRegisterNewTeam() ?
: null}
{hasFriendCodeSet ? (
- showRegistrationProgress() ? (
-
- ) : (
-
- This tournament is invitational. Tournament organizer adds all
- teams.
-
- )
+
) : null}
{showRegisterNewTeam() && hasFriendCodeSet ? (
) : null}
{tournament.isLeague &&
@@ -682,6 +670,7 @@ function FillRoster({
const showDeleteMemberSection =
!readOnly &&
+ !tournament.isInvitational &&
((!ownTeamCheckedIn && ownTeamMembers.length > 1) ||
(ownTeamCheckedIn &&
ownTeamMembers.length > tournament.minMembersPerTeam));
diff --git a/changelog/2026-08-29-invitational-roster-kick.md b/changelog/2026-08-29-invitational-roster-kick.md
new file mode 100644
index 000000000..52ed88369
--- /dev/null
+++ b/changelog/2026-08-29-invitational-roster-kick.md
@@ -0,0 +1,5 @@
+---
+navItem: calendar
+type: bug
+---
+Captains of an invitational tournament team can no longer kick members or unregister the team on their own
diff --git a/e2e/pages/tournament/tournament-register-page.ts b/e2e/pages/tournament/tournament-register-page.ts
index 9b1e6166c..1e85f06b6 100644
--- a/e2e/pages/tournament/tournament-register-page.ts
+++ b/e2e/pages/tournament/tournament-register-page.ts
@@ -28,6 +28,8 @@ export class TournamentRegisterPage {
"Registration for this tournament has closed",
),
leaveTeamButton: page.getByRole("button", { name: "Leave the team" }),
+ deleteMemberButton: page.getByRole("button", { name: "Delete member" }),
+ unregisterButton: page.getByRole("button", { name: "Unregister" }),
organizerAddedLeaveExplanation: page.getByText(
"You were added to the team by the organizer. Contact the TO to leave the team.",
),
diff --git a/e2e/tournament-invitational.spec.ts b/e2e/tournament-invitational.spec.ts
index 3b960bc8f..e1d21a777 100644
--- a/e2e/tournament-invitational.spec.ts
+++ b/e2e/tournament-invitational.spec.ts
@@ -85,6 +85,13 @@ test.describe("Invitational tournament", () => {
await expect(
register.locators.organizerAddedLeaveExplanation,
).toBeVisible();
+
+ // nor can the captain kick them, the organizer owns the roster
+ await impersonate(page, captain.id);
+ await register.goto(tournament.id);
+ await expect(register.member(2)).toBeVisible();
+ await expect(register.locators.deleteMemberButton).toHaveCount(0);
+ await expect(register.locators.unregisterButton).toHaveCount(0);
});
});