Tournament organizations use new permissions model & e2e test added

This commit is contained in:
Kalle 2025-06-08 21:39:38 +03:00
parent c70a9b82ab
commit 9afb5df064
8 changed files with 112 additions and 46 deletions

View File

@ -42,6 +42,7 @@ import * as QRepository from "~/features/sendouq/QRepository.server";
import { addMember } from "~/features/sendouq/queries/addMember.server";
import { createMatch } from "~/features/sendouq/queries/createMatch.server";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { createVod } from "~/features/vods/queries/createVod.server";
@ -174,6 +175,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
scrimPostRequests,
associations,
notifications,
organization,
];
export async function seed(variation?: SeedVariation | null) {
@ -235,6 +237,7 @@ function wipeDB() {
"PlusVote",
"TournamentBadgeOwner",
"BadgeManager",
"TournamentOrganization",
];
for (const table of tablesToDelete) {
@ -2550,3 +2553,34 @@ async function notifications() {
});
}
}
async function organization() {
await TournamentOrganizationRepository.create({
ownerId: ADMIN_ID,
name: "sendou.ink",
});
await TournamentOrganizationRepository.update({
id: 1,
name: "sendou.ink",
description: "Sendou.ink official tournaments",
socials: [
"https://bsky.app/profile/sendou.ink",
"https://twitch.tv/sendou",
],
members: [
{
userId: ADMIN_ID,
role: "ADMIN",
roleDisplayName: null,
},
{
userId: NZAP_TEST_ID,
role: "MEMBER",
roleDisplayName: null,
},
],
series: [],
badges: [],
});
}

View File

@ -10,14 +10,13 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { isTeamManager } from "~/features/team/team-utils";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import { canEditTournamentOrganization } from "~/features/tournament-organization/tournament-organization-utils";
import { requirePermission } from "~/modules/permissions/guards.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseSearchParams,
unauthorizedIfFalsy,
} from "~/utils/remix.server";
import { teamPage, tournamentOrganizationPage } from "~/utils/urls";
import { addNewImage } from "../queries/addNewImage";
@ -38,7 +37,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
: undefined;
const organization =
validatedType === "org-pfp"
? await validatedOrg({ user, request })
? await requireEditableOrganization({ user, request })
: undefined;
errorToastIfFalsy(
@ -105,7 +104,7 @@ async function validatedTeam({
return team;
}
async function validatedOrg({
async function requireEditableOrganization({
user,
request,
}: { user: { id: number }; request: Request }) {
@ -117,7 +116,7 @@ async function validatedOrg({
await TournamentOrganizationRepository.findBySlug(slug),
);
unauthorizedIfFalsy(canEditTournamentOrganization({ user, organization }));
requirePermission(organization, "EDIT", user);
return organization;
}

View File

@ -35,8 +35,8 @@ export function create(args: CreateArgs) {
});
}
export function findBySlug(slug: string) {
return db
export async function findBySlug(slug: string) {
const organization = await db
.selectFrom("TournamentOrganization")
.leftJoin(
"UserSubmittedImage",
@ -95,6 +95,17 @@ export function findBySlug(slug: string) {
])
.where("TournamentOrganization.slug", "=", slug)
.executeTakeFirst();
if (!organization) return null;
return {
...organization,
permissions: {
EDIT: organization.members
.filter((member) => member.role === "ADMIN")
.map((member) => member.id),
},
};
}
export function findByOrganizerUserId(userId: number) {

View File

@ -2,16 +2,12 @@ import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import i18next from "~/modules/i18n/i18next.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { valueArrayToDBFormat } from "~/utils/form";
import {
actionError,
parseRequestPayload,
unauthorizedIfFalsy,
} from "~/utils/remix.server";
import { actionError, parseRequestPayload } from "~/utils/remix.server";
import { tournamentOrganizationPage } from "~/utils/urls";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { organizationEditSchema } from "../tournament-organization-schemas";
import { canEditTournamentOrganization } from "../tournament-organization-utils";
import { organizationFromParams } from "../tournament-organization-utils.server";
export const action = async ({ request, params }: ActionFunctionArgs) => {
@ -24,7 +20,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const organization = await organizationFromParams(params);
unauthorizedIfFalsy(canEditTournamentOrganization({ organization, user }));
requirePermission(organization, "EDIT", user);
if (
!data.members.some(

View File

@ -1,15 +1,14 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import { unauthorizedIfFalsy } from "~/utils/remix.server";
import { canEditTournamentOrganization } from "../tournament-organization-utils";
import { requirePermission } from "~/modules/permissions/guards.server";
import { organizationFromParams } from "../tournament-organization-utils.server";
export async function loader({ params, request }: LoaderFunctionArgs) {
const user = await requireUser(request);
const organization = await organizationFromParams(params);
unauthorizedIfFalsy(canEditTournamentOrganization({ organization, user }));
requirePermission(organization, "EDIT", user);
const badgeOptions = async () => {
const result = await BadgeRepository.findByManagersList(

View File

@ -9,9 +9,9 @@ import { NewTabs } from "~/components/NewTabs";
import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
import { EditIcon } from "~/components/icons/Edit";
import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHasPermission } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -27,7 +27,6 @@ import {
import { EventCalendar } from "../components/EventCalendar";
import { SocialLinksList } from "../components/SocialLinksList";
import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "../tournament-organization-constants";
import { canEditTournamentOrganization } from "../tournament-organization-utils";
import { loader } from "../loaders/org.$slug.server";
export { loader };
@ -100,8 +99,8 @@ export default function TournamentOrganizationPage() {
function LogoHeader() {
const { t } = useTranslation(["common"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const canEditOrganization = useHasPermission(data.organization, "EDIT");
return (
<div className="stack horizontal md">
@ -115,16 +114,14 @@ function LogoHeader() {
/>
<div className="stack sm">
<div className="text-xl font-bold">{data.organization.name}</div>
{canEditTournamentOrganization({
user,
organization: data.organization,
}) ? (
{canEditOrganization ? (
<div className="stack items-start">
<LinkButton
to={tournamentOrganizationEditPage(data.organization.slug)}
icon={<EditIcon />}
size="tiny"
variant="outlined"
testId="edit-org-button"
>
{t("common:actions.edit")}
</LinkButton>

View File

@ -1,22 +0,0 @@
import { isAdmin } from "~/modules/permissions/utils";
import type { UnwrappedNonNullable } from "~/utils/types";
import type * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
export function canEditTournamentOrganization({
user,
organization,
}: {
user?: { id: number };
organization: Pick<
UnwrappedNonNullable<typeof TournamentOrganizationRepository.findBySlug>,
"members"
>;
}) {
if (isAdmin(user)) {
return true;
}
return organization.members.some(
(member) => member.id === user?.id && member.role === "ADMIN",
);
}

52
e2e/org.spec.ts Normal file
View File

@ -0,0 +1,52 @@
import test, { expect } from "@playwright/test";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import {
impersonate,
isNotVisible,
navigate,
seed,
submit,
} from "~/utils/playwright";
import { tournamentOrganizationPage } from "~/utils/urls";
const url = tournamentOrganizationPage({
organizationSlug: "sendouink",
});
test.describe("Tournament Organization", () => {
test("user can be promoted to admin gaining org controls", async ({
page,
}) => {
await seed(page);
const editButtonLocator = page.getByTestId("edit-org-button");
// 1. As a regular user, verify edit controls are not visible
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url,
});
await isNotVisible(editButtonLocator);
// 2. As admin, promote user to admin
await impersonate(page, ADMIN_ID);
await navigate({ page, url });
await editButtonLocator.click();
// Add member as admin
await page.getByLabel("Role").first().selectOption("ADMIN");
await submit(page);
// 3. As the promoted user, verify edit controls are visible and page can be accessed
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url,
});
await editButtonLocator.click();
await expect(
page.getByText("Editing tournament organization"),
).toBeVisible();
});
});