mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Global roles refactor (#2212)
* Initial * isMod etc. * canPerformAdminActions * isAdmin * isSupporter * admin override * Lohi * Badge manage with new permissions style * Refactor badge loading logic * Move funcs * Delete permissions.ts * DRY
This commit is contained in:
parent
d28f348629
commit
d2551d2706
|
|
@ -1,7 +1,7 @@
|
|||
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
|
||||
export const Main = ({
|
||||
children,
|
||||
|
|
@ -19,10 +19,10 @@ export const Main = ({
|
|||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
const error = useRouteError();
|
||||
const user = useUser();
|
||||
const isMinorSupporter = useHasRole("MINOR_SUPPORT");
|
||||
const showLeaderboard =
|
||||
import.meta.env.VITE_PLAYWIRE_PUBLISHER_ID &&
|
||||
!user?.patronTier &&
|
||||
!isMinorSupporter &&
|
||||
!isRouteErrorResponse(error);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const Layout = React.memo(function Layout({
|
|||
|
||||
const showLeaderboard =
|
||||
import.meta.env.VITE_PLAYWIRE_PUBLISHER_ID &&
|
||||
!data?.user?.patronTier &&
|
||||
!data?.user?.roles.includes("MINOR_SUPPORT") &&
|
||||
!location.pathname.includes("plans");
|
||||
return (
|
||||
<div className="layout__container">
|
||||
|
|
@ -81,7 +81,7 @@ export const Layout = React.memo(function Layout({
|
|||
<TopRightButtons
|
||||
isErrored={isErrored}
|
||||
showSupport={Boolean(
|
||||
data && typeof data?.user?.patronTier !== "number" && isFrontPage,
|
||||
data && !data?.user?.roles.includes("MINOR_SUPPORT") && isFrontPage,
|
||||
)}
|
||||
openNavDialog={() => setNavDialogOpen(true)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ export const ADMIN_DISCORD_ID = "79237403620945920";
|
|||
export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;
|
||||
|
||||
// Panda Scep
|
||||
export const MOD_IDS = [11329, 9719];
|
||||
export const MOD_DISCORD_IDS = ["138757634500067328", "184478601171828737"];
|
||||
export const STAFF_IDS = [11329, 9719];
|
||||
export const STAFF_DISCORD_IDS = ["138757634500067328", "184478601171828737"];
|
||||
|
||||
export const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";
|
||||
export const SKALOP_TOKEN_HEADER_NAME = "Skalop-Token";
|
||||
|
|
|
|||
|
|
@ -2,16 +2,12 @@ import type { ActionFunctionArgs } from "@remix-run/node";
|
|||
import { z } from "zod";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import { makeArtist } from "~/features/art/queries/makeArtist.server";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { refreshBannedCache } from "~/features/ban/core/banned.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isAdmin, isMod } from "~/permissions";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { parseRequestPayload, successToast } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { _action, actualNumber, friendCode } from "~/utils/zod";
|
||||
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
|
||||
|
|
@ -21,12 +17,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
request,
|
||||
schema: adminActionSchema,
|
||||
});
|
||||
const user = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
|
||||
let message: string;
|
||||
switch (data._action) {
|
||||
case "MIGRATE": {
|
||||
errorToastIfFalsy(isMod(user), "Admin needed");
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
await AdminRepository.migrate({
|
||||
oldUserId: data["old-user"],
|
||||
|
|
@ -37,7 +33,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "REFRESH": {
|
||||
errorToastIfFalsy(isAdmin(user), "Admin needed");
|
||||
requireRole(user, "ADMIN");
|
||||
|
||||
await AdminRepository.replacePlusTiers(
|
||||
await plusTiersFromVotingAndLeaderboard(),
|
||||
|
|
@ -47,7 +43,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "FORCE_PATRON": {
|
||||
errorToastIfFalsy(isAdmin(user), "Admin needed");
|
||||
requireRole(user, "ADMIN");
|
||||
|
||||
await AdminRepository.forcePatron({
|
||||
id: data.user,
|
||||
|
|
@ -60,7 +56,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "CLEAN_UP": {
|
||||
errorToastIfFalsy(isAdmin(user), "Admin needed");
|
||||
requireRole(user, "ADMIN");
|
||||
|
||||
// on purpose sync
|
||||
AdminRepository.cleanUp();
|
||||
|
|
@ -69,7 +65,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "ARTIST": {
|
||||
errorToastIfFalsy(isMod(user), "Mod needed");
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
makeArtist(data.user);
|
||||
|
||||
|
|
@ -77,7 +73,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "VIDEO_ADDER": {
|
||||
errorToastIfFalsy(isMod(user), "Mod needed");
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
await AdminRepository.makeVideoAdderByUserId(data.user);
|
||||
|
||||
|
|
@ -85,7 +81,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "TOURNAMENT_ORGANIZER": {
|
||||
errorToastIfFalsy(isMod(user), "Mod needed");
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
await AdminRepository.makeTournamentOrganizerByUserId(data.user);
|
||||
|
||||
|
|
@ -93,7 +89,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "LINK_PLAYER": {
|
||||
errorToastIfFalsy(isMod(user), "Mod needed");
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
await AdminRepository.linkUserAndPlayer({
|
||||
userId: data.user,
|
||||
|
|
@ -104,7 +100,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "BAN_USER": {
|
||||
errorToastIfFalsy(isMod(user), "Mod needed");
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
await AdminRepository.banUser({
|
||||
bannedReason: data.reason ?? null,
|
||||
|
|
@ -127,7 +123,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "UNBAN_USER": {
|
||||
errorToastIfFalsy(isMod(user), "Mod needed");
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
await AdminRepository.unbanUser(data.user);
|
||||
|
||||
|
|
@ -142,7 +138,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
break;
|
||||
}
|
||||
case "UPDATE_FRIEND_CODE": {
|
||||
errorToastIfFalsy(isMod(user), "Mod needed");
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
await UserRepository.insertFriendCode({
|
||||
friendCode: data.friendCode,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUserId, isImpersonating } from "~/features/auth/core/user.server";
|
||||
import { isImpersonating, requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { parseSafeSearchParams } from "~/utils/remix.server";
|
||||
import { adminActionSearchParamsSchema } from "../admin-schemas";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
// allow unauthorized access in development mode to access impersonation controls
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const user = await requireUser(request);
|
||||
requireRole(user, "STAFF");
|
||||
}
|
||||
|
||||
const parsedSearchParams = parseSafeSearchParams({
|
||||
request,
|
||||
schema: adminActionSearchParamsSchema,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === "production" && !isMod(user)) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
return {
|
||||
isImpersonating: await isImpersonating(request),
|
||||
friendCodeSearchUsers: parsedSearchParams.success
|
||||
|
|
|
|||
|
|
@ -17,9 +17,8 @@ import { NewTabs } from "~/components/NewTabs";
|
|||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { SearchIcon } from "~/components/icons/Search";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
||||
import { isAdmin, isMod } from "~/permissions";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import {
|
||||
SEED_URL,
|
||||
|
|
@ -108,27 +107,28 @@ function FriendCodeLookUp() {
|
|||
}
|
||||
|
||||
function AdminActions() {
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{process.env.NODE_ENV !== "production" && <Seed />}
|
||||
|
||||
{isMod(user) ? <LinkPlayer /> : null}
|
||||
{isMod(user) ? <GiveArtist /> : null}
|
||||
{isMod(user) ? <GiveVideoAdder /> : null}
|
||||
{isMod(user) ? <GiveTournamentOrganizer /> : null}
|
||||
{isMod(user) ? <UpdateFriendCode /> : null}
|
||||
{isStaff ? <LinkPlayer /> : null}
|
||||
{isStaff ? <GiveArtist /> : null}
|
||||
{isStaff ? <GiveVideoAdder /> : null}
|
||||
{isStaff ? <GiveTournamentOrganizer /> : null}
|
||||
{isStaff ? <UpdateFriendCode /> : null}
|
||||
|
||||
{process.env.NODE_ENV !== "production" || isAdmin(user) ? (
|
||||
{process.env.NODE_ENV !== "production" || isAdmin ? (
|
||||
<Impersonate />
|
||||
) : null}
|
||||
{isMod(user) ? <MigrateUser /> : null}
|
||||
{isAdmin(user) ? <ForcePatron /> : null}
|
||||
{isMod(user) ? <BanUser /> : null}
|
||||
{isMod(user) ? <UnbanUser /> : null}
|
||||
{isAdmin(user) ? <RefreshPlusTiers /> : null}
|
||||
{isAdmin(user) ? <CleanUp /> : null}
|
||||
{isStaff ? <MigrateUser /> : null}
|
||||
{isAdmin ? <ForcePatron /> : null}
|
||||
{isStaff ? <BanUser /> : null}
|
||||
{isStaff ? <UnbanUser /> : null}
|
||||
{isAdmin ? <RefreshPlusTiers /> : null}
|
||||
{isAdmin ? <CleanUp /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { updatePatreonData } from "~/modules/patreon";
|
||||
import { canAccessLohiEndpoint, canPerformAdminActions } from "~/permissions";
|
||||
import { unauthorizedIfFalsy } from "~/utils/remix.server";
|
||||
import {
|
||||
canAccessLohiEndpoint,
|
||||
unauthorizedIfFalsy,
|
||||
} from "~/utils/remix.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await getUserId(request);
|
||||
|
||||
if (!canPerformAdminActions(user) && !canAccessLohiEndpoint(request)) {
|
||||
if (!canAccessLohiEndpoint(request)) {
|
||||
throw new Response("Not authorized", { status: 403 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { canAccessLohiEndpoint } from "~/permissions";
|
||||
import { canAccessLohiEndpoint } from "~/utils/remix.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
if (!canAccessLohiEndpoint(request)) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Main } from "~/components/Main";
|
|||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
|
|
@ -52,7 +52,7 @@ export default function NewArtPage() {
|
|||
const { t } = useTranslation(["common", "art"]);
|
||||
const ref = React.useRef<HTMLFormElement>(null);
|
||||
const fetcher = useFetcher();
|
||||
const user = useUser();
|
||||
const isArtist = useHasRole("ARTIST");
|
||||
|
||||
const handleSubmit = () => {
|
||||
const formData = new FormData(ref.current!);
|
||||
|
|
@ -72,7 +72,7 @@ export default function NewArtPage() {
|
|||
return !img && !data.art;
|
||||
};
|
||||
|
||||
if (!user || !user.isArtist) {
|
||||
if (!isArtist) {
|
||||
return (
|
||||
<Main className="stack items-center">
|
||||
<Alert variation="WARNING">{t("art:gainPerms")}</Alert>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { createNewAssociationSchema } from "~/features/associations/associations
|
|||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { actionError, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { associationsPage } from "~/utils/urls";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import * as AssociationRepository from "../AssociationRepository.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
|
@ -17,7 +16,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
const associationCount = (
|
||||
await AssociationRepository.findByMemberUserId(user.id)
|
||||
).actual;
|
||||
const maxAssociationCount = isAtLeastFiveDollarTierPatreon(user)
|
||||
const maxAssociationCount = user.roles.includes("SUPPORTER")
|
||||
? ASSOCIATION.MAX_COUNT_SUPPORTER
|
||||
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import * as AssociationRepository from "../AssociationRepository.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
|
@ -75,7 +74,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
"Association is full",
|
||||
);
|
||||
|
||||
const maxAssociationCount = isAtLeastFiveDollarTierPatreon(user)
|
||||
const maxAssociationCount = user.roles.includes("SUPPORTER")
|
||||
? ASSOCIATION.MAX_COUNT_SUPPORTER
|
||||
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ import { redirect } from "@remix-run/node";
|
|||
import { isbot } from "isbot";
|
||||
import { z } from "zod";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { canAccessLohiEndpoint, canPerformAdminActions } from "~/permissions";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { errorToastIfFalsy, parseSearchParams } from "~/utils/remix.server";
|
||||
import {
|
||||
canAccessLohiEndpoint,
|
||||
errorToastIfFalsy,
|
||||
parseSearchParams,
|
||||
} from "~/utils/remix.server";
|
||||
import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls";
|
||||
import { createLogInLink } from "../queries/createLogInLink.server";
|
||||
import { deleteLogInLinkByCode } from "../queries/deleteLogInLinkByCode.server";
|
||||
|
|
@ -16,7 +20,7 @@ import {
|
|||
authenticator,
|
||||
} from "./authenticator.server";
|
||||
import { authSessionStorage } from "./session.server";
|
||||
import { getUserId } from "./user.server";
|
||||
import { getUserId, requireUser } from "./user.server";
|
||||
|
||||
export const callbackLoader: LoaderFunction = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
|
|
@ -70,9 +74,9 @@ export const logInAction: ActionFunction = async ({ request }) => {
|
|||
};
|
||||
|
||||
export const impersonateAction: ActionFunction = async ({ request }) => {
|
||||
const user = await getUserId(request);
|
||||
if (!canPerformAdminActions(user)) {
|
||||
throw new Response(null, { status: 403 });
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const user = await requireUser(request);
|
||||
requireRole(user, "ADMIN");
|
||||
}
|
||||
|
||||
const session = await authSessionStorage.getSession(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,53 @@
|
|||
import type { ExpressionBuilder } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
|
||||
const addPermissions = <T extends { managers: { userId: number }[] }>(
|
||||
row: T,
|
||||
) => ({
|
||||
...row,
|
||||
permissions: {
|
||||
MANAGE: row.managers.map((m) => m.userId),
|
||||
},
|
||||
});
|
||||
|
||||
const withAuthor = (eb: ExpressionBuilder<DB, "Badge">) => {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
.select(COMMON_USER_FIELDS)
|
||||
.whereRef("User.id", "=", "Badge.authorId"),
|
||||
).as("author");
|
||||
};
|
||||
|
||||
const withManagers = (eb: ExpressionBuilder<DB, "Badge">) => {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("BadgeManager")
|
||||
.innerJoin("User", "BadgeManager.userId", "User.id")
|
||||
.select(["userId", ...COMMON_USER_FIELDS])
|
||||
.whereRef("BadgeManager.badgeId", "=", "Badge.id"),
|
||||
).as("managers");
|
||||
};
|
||||
|
||||
const withOwners = (eb: ExpressionBuilder<DB, "Badge">) => {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("BadgeOwner")
|
||||
.innerJoin("User", "BadgeOwner.userId", "User.id")
|
||||
.select(({ fn }) => [
|
||||
fn.count<number>("BadgeOwner.badgeId").as("count"),
|
||||
"User.id",
|
||||
"User.discordId",
|
||||
"User.username",
|
||||
])
|
||||
.whereRef("BadgeOwner.badgeId", "=", "Badge.id")
|
||||
.groupBy("User.id")
|
||||
.orderBy("count", "desc"),
|
||||
).as("owners");
|
||||
};
|
||||
|
||||
export async function all() {
|
||||
const rows = await db
|
||||
|
|
@ -11,33 +57,34 @@ export async function all() {
|
|||
"displayName",
|
||||
"code",
|
||||
"hue",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("BadgeManager")
|
||||
.whereRef("BadgeManager.badgeId", "=", "Badge.id")
|
||||
.select(["userId"]),
|
||||
).as("managers"),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
.select(COMMON_USER_FIELDS)
|
||||
.whereRef("User.id", "=", "Badge.authorId"),
|
||||
).as("author"),
|
||||
withManagers(eb),
|
||||
withAuthor(eb),
|
||||
])
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
managers: row.managers.map((m) => m.userId),
|
||||
}));
|
||||
return rows.map(addPermissions);
|
||||
}
|
||||
|
||||
export async function findById(badgeId: number) {
|
||||
return db
|
||||
const row = await db
|
||||
.selectFrom("Badge")
|
||||
.select(["Badge.displayName"])
|
||||
.select((eb) => [
|
||||
"Badge.id",
|
||||
"Badge.displayName",
|
||||
"Badge.code",
|
||||
"Badge.hue",
|
||||
withAuthor(eb),
|
||||
withManagers(eb),
|
||||
withOwners(eb),
|
||||
])
|
||||
.where("id", "=", badgeId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return addPermissions(row);
|
||||
}
|
||||
|
||||
export async function findByOwnerId({
|
||||
|
|
@ -97,32 +144,6 @@ export function findManagedByUserId(userId: number) {
|
|||
.execute();
|
||||
}
|
||||
|
||||
export function findManagersByBadgeId(badgeId: number) {
|
||||
return db
|
||||
.selectFrom("BadgeManager")
|
||||
.innerJoin("User", "BadgeManager.userId", "User.id")
|
||||
.select(COMMON_USER_FIELDS)
|
||||
.where("BadgeManager.badgeId", "=", badgeId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export type FindOwnersByBadgeIdItem = Unwrapped<typeof findOwnersByBadgeId>;
|
||||
export function findOwnersByBadgeId(badgeId: number) {
|
||||
return db
|
||||
.selectFrom("BadgeOwner")
|
||||
.innerJoin("User", "BadgeOwner.userId", "User.id")
|
||||
.select(({ fn }) => [
|
||||
fn.count<number>("BadgeOwner.badgeId").as("count"),
|
||||
"User.id",
|
||||
"User.discordId",
|
||||
"User.username",
|
||||
])
|
||||
.where("BadgeOwner.badgeId", "=", badgeId)
|
||||
.groupBy("User.id")
|
||||
.orderBy("count", "desc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function replaceManagers({
|
||||
badgeId,
|
||||
managerIds,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions";
|
||||
import { diff } from "~/utils/arrays";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
requirePermission,
|
||||
requireRole,
|
||||
} from "~/modules/permissions/guards.server";
|
||||
import { diff } from "~/utils/arrays";
|
||||
import { notFoundIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { badgePage } from "~/utils/urls";
|
||||
import { actualNumber } from "~/utils/zod";
|
||||
|
|
@ -22,18 +21,15 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
schema: editBadgeActionSchema,
|
||||
});
|
||||
const badgeId = z.preprocess(actualNumber, z.number()).parse(params.id);
|
||||
const user = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
|
||||
const badge = notFoundIfFalsy(await BadgeRepository.findById(badgeId));
|
||||
|
||||
switch (data._action) {
|
||||
case "MANAGERS": {
|
||||
errorToastIfFalsy(
|
||||
canEditBadgeManagers(user),
|
||||
"No permissions to edit managers",
|
||||
);
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
const oldManagers = await BadgeRepository.findManagersByBadgeId(badgeId);
|
||||
const oldManagers = badge.managers;
|
||||
|
||||
await BadgeRepository.replaceManagers({
|
||||
badgeId,
|
||||
|
|
@ -42,7 +38,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
|
||||
const newManagers = data.managerIds.filter(
|
||||
(newManagerId) =>
|
||||
!oldManagers.some((oldManager) => oldManager.id === newManagerId),
|
||||
!oldManagers.some((oldManager) => oldManager.userId === newManagerId),
|
||||
);
|
||||
|
||||
notify({
|
||||
|
|
@ -58,17 +54,11 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
break;
|
||||
}
|
||||
case "OWNERS": {
|
||||
errorToastIfFalsy(
|
||||
canEditBadgeOwners({
|
||||
user,
|
||||
managers: await BadgeRepository.findManagersByBadgeId(badgeId),
|
||||
}),
|
||||
"No permissions to edit owners",
|
||||
);
|
||||
requirePermission(badge, "MANAGE", user);
|
||||
|
||||
const oldOwners: number[] = (
|
||||
await BadgeRepository.findOwnersByBadgeId(badgeId)
|
||||
).flatMap((owner) => new Array(owner.count).fill(owner.id));
|
||||
const oldOwners: number[] = badge.owners.flatMap((owner) =>
|
||||
new Array(owner.count).fill(owner.id),
|
||||
);
|
||||
|
||||
await BadgeRepository.replaceOwners({ badgeId, ownerIds: data.ownerIds });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
|
||||
export type BadgeDetailsLoaderData = SerializeFrom<typeof loader>;
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const badgeId = Number(params.id);
|
||||
if (Number.isNaN(badgeId)) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
const { id } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
});
|
||||
const badge = notFoundIfFalsy(await BadgeRepository.findById(id));
|
||||
|
||||
return {
|
||||
owners: await BadgeRepository.findOwnersByBadgeId(badgeId),
|
||||
managers: await BadgeRepository.findManagersByBadgeId(badgeId),
|
||||
badge,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@ import { Label } from "~/components/Label";
|
|||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions";
|
||||
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import type * as BadgeRepository from "../BadgeRepository.server";
|
||||
import type { BadgeDetailsLoaderData } from "../loaders/badges.$id.server";
|
||||
import type { BadgeDetailsContext } from "./badges.$id";
|
||||
|
||||
|
|
@ -17,17 +15,18 @@ import { action } from "../actions/badges.$id.edit.server";
|
|||
export { action };
|
||||
|
||||
export default function EditBadgePage() {
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const matches = useMatches();
|
||||
const data = atOrError(matches, -2).data as BadgeDetailsLoaderData;
|
||||
const { badgeName } = useOutletContext<BadgeDetailsContext>();
|
||||
const { badge } = useOutletContext<BadgeDetailsContext>();
|
||||
const canManageBadge = useHasPermission(badge, "MANAGE");
|
||||
|
||||
return (
|
||||
<Dialog isOpen>
|
||||
<Form method="post" className="stack md">
|
||||
<div>
|
||||
<h2 className="badges-edit__big-header">
|
||||
Editing winners of {badgeName}
|
||||
Editing winners of {badge.displayName}
|
||||
</h2>
|
||||
<LinkButton
|
||||
to={atOrError(matches, -2).pathname}
|
||||
|
|
@ -39,31 +38,31 @@ export default function EditBadgePage() {
|
|||
</LinkButton>
|
||||
</div>
|
||||
|
||||
{canEditBadgeManagers(user) ? <Managers data={data} /> : null}
|
||||
{canEditBadgeOwners({ user, managers: data.managers }) ? (
|
||||
<Owners data={data} />
|
||||
) : null}
|
||||
{isStaff ? <Managers data={data} /> : null}
|
||||
{canManageBadge ? <Owners data={data} /> : null}
|
||||
</Form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Managers({ data }: { data: BadgeDetailsLoaderData }) {
|
||||
const [managers, setManagers] = React.useState(data.managers);
|
||||
const [managers, setManagers] = React.useState<
|
||||
Array<{ id: number; username: string }>
|
||||
>(data.badge.managers);
|
||||
|
||||
const amountOfChanges = managers
|
||||
.filter((m) => !data.managers.some((om) => om.id === m.id))
|
||||
.filter((m) => !data.badge.managers.some((om) => om.id === m.id))
|
||||
// maps to id to keep typescript happy
|
||||
.map((m) => m.id)
|
||||
// needed so we can also list amount of removed managers
|
||||
.concat(
|
||||
data.managers
|
||||
data.badge.managers
|
||||
.filter((om) => !managers.some((m) => m.id === om.id))
|
||||
.map((m) => m.id),
|
||||
).length;
|
||||
|
||||
const userIdsToOmitFromCombobox = React.useMemo(() => {
|
||||
return new Set(data.managers.map((m) => m.id));
|
||||
return new Set(data.badge.managers.map((m) => m.id));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
|
|
@ -130,9 +129,9 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
|
|||
}
|
||||
|
||||
function Owners({ data }: { data: BadgeDetailsLoaderData }) {
|
||||
const [owners, setOwners] = React.useState(data.owners);
|
||||
const [owners, setOwners] = React.useState(data.badge.owners);
|
||||
|
||||
const ownerDifferences = getOwnerDifferences(owners, data.owners);
|
||||
const ownerDifferences = getOwnerDifferences(owners, data.badge.owners);
|
||||
|
||||
const userInputKey = owners.map((o) => `${o.id}-${o.count}`).join("-");
|
||||
|
||||
|
|
@ -230,8 +229,8 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
|
|||
}
|
||||
|
||||
function getOwnerDifferences(
|
||||
newOwners: BadgeRepository.FindOwnersByBadgeIdItem[],
|
||||
oldOwners: BadgeRepository.FindOwnersByBadgeIdItem[],
|
||||
newOwners: BadgeDetailsLoaderData["badge"]["owners"],
|
||||
oldOwners: BadgeDetailsLoaderData["badge"]["owners"],
|
||||
) {
|
||||
const result: Array<{
|
||||
id: Tables["User"]["id"];
|
||||
|
|
|
|||
|
|
@ -1,44 +1,37 @@
|
|||
import { Outlet, useLoaderData, useMatches, useParams } from "@remix-run/react";
|
||||
import { Outlet, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { canEditBadgeOwners, isMod } from "~/permissions";
|
||||
import { BADGES_PAGE } from "~/utils/urls";
|
||||
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { badgeExplanationText } from "../badges-utils";
|
||||
import type { BadgesLoaderData } from "../loaders/badges.server";
|
||||
|
||||
import { loader } from "../loaders/badges.$id.server";
|
||||
export { loader };
|
||||
|
||||
export interface BadgeDetailsContext {
|
||||
badgeName: string;
|
||||
badge: SerializeFrom<typeof loader>["badge"];
|
||||
}
|
||||
|
||||
export default function BadgeDetailsPage() {
|
||||
const user = useUser();
|
||||
const [, parentRoute] = useMatches();
|
||||
const { badges } = parentRoute.data as BadgesLoaderData;
|
||||
const params = useParams();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation("badges");
|
||||
|
||||
const badge = badges.find((b) => b.id === Number(params.id));
|
||||
if (!badge) return <Redirect to={BADGES_PAGE} />;
|
||||
const canManageBadge = useHasPermission(data.badge, "MANAGE");
|
||||
|
||||
const context: BadgeDetailsContext = { badgeName: badge.displayName };
|
||||
const context: BadgeDetailsContext = { badge: data.badge };
|
||||
|
||||
const badgeMaker = () => {
|
||||
if (badge.author?.username) return badge.author?.username;
|
||||
if (data.badge.author?.username) return data.badge.author?.username;
|
||||
if (
|
||||
[
|
||||
"XP3500 (Splatoon 3)",
|
||||
"XP4000 (Splatoon 3)",
|
||||
"XP4500 (Splatoon 3)",
|
||||
"XP5000 (Splatoon 3)",
|
||||
].includes(badge.displayName)
|
||||
].includes(data.badge.displayName)
|
||||
) {
|
||||
return "Dreamy";
|
||||
}
|
||||
|
|
@ -49,14 +42,15 @@ export default function BadgeDetailsPage() {
|
|||
return (
|
||||
<div className="stack md items-center">
|
||||
<Outlet context={context} />
|
||||
<Badge badge={badge} isAnimated size={200} />
|
||||
<Badge badge={data.badge} isAnimated size={200} />
|
||||
<div>
|
||||
<div className="badges__explanation">
|
||||
{badgeExplanationText(t, badge)}
|
||||
{badgeExplanationText(t, data.badge)}
|
||||
</div>
|
||||
<div className="badges__managers">
|
||||
{t("managedBy", {
|
||||
users: data.managers.map((m) => m.username).join(", ") || "???",
|
||||
users:
|
||||
data.badge.managers.map((m) => m.username).join(", ") || "???",
|
||||
})}{" "}
|
||||
(
|
||||
{t("madeBy", {
|
||||
|
|
@ -65,14 +59,14 @@ export default function BadgeDetailsPage() {
|
|||
)
|
||||
</div>
|
||||
</div>
|
||||
{isMod(user) || canEditBadgeOwners({ user, managers: data.managers }) ? (
|
||||
{isStaff || canManageBadge ? (
|
||||
<LinkButton to="edit" variant="outlined" size="tiny">
|
||||
Edit
|
||||
</LinkButton>
|
||||
) : null}
|
||||
<div className="badges__owners-container">
|
||||
<ul className="badges__owners">
|
||||
{data.owners.map((owner) => (
|
||||
{data.badge.owners.map((owner) => (
|
||||
<li key={owner.id}>
|
||||
<span
|
||||
className={clsx("badges__count", {
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ function splitBadges(
|
|||
const otherBadges: BadgesLoaderData["badges"] = [];
|
||||
|
||||
for (const badge of badges) {
|
||||
if (user && badge.managers.includes(user?.id)) {
|
||||
if (user && badge.permissions.MANAGE.includes(user.id)) {
|
||||
ownBadges.push(badge);
|
||||
} else {
|
||||
otherBadges.push(badge);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { ActionFunction } from "@remix-run/node";
|
|||
import { redirect } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { canReportCalendarEventWinners } from "~/permissions";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
|
|
@ -13,6 +12,7 @@ import {
|
|||
reportWinnersActionSchema,
|
||||
reportWinnersParamsSchema,
|
||||
} from "../calendar-schemas";
|
||||
import { canReportCalendarEventWinners } from "../calendar-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import {
|
|||
clearTournamentDataCache,
|
||||
tournamentManagerData,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { canDeleteCalendarEvent } from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { errorToastIfFalsy, notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { CALENDAR_PAGE } from "~/utils/urls";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
import { canDeleteCalendarEvent } from "../calendar-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ params, request }) => {
|
||||
const user = await requireUserId(request);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { canEditCalendarEvent } from "~/permissions";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
|
|
@ -41,10 +41,10 @@ import {
|
|||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "../calendar-constants";
|
||||
import { canEditCalendarEvent } from "../calendar-utils";
|
||||
import {
|
||||
calendarEventMaxDate,
|
||||
calendarEventMinDate,
|
||||
canAddNewEvent,
|
||||
regClosesAtDate,
|
||||
} from "../calendar-utils";
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
parseAsync: true,
|
||||
});
|
||||
|
||||
errorToastIfFalsy(canAddNewEvent(user), "Not authorized");
|
||||
requireRole(user, "CALENDAR_EVENT_ADDER");
|
||||
|
||||
const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date));
|
||||
const commonArgs = {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { allTruthy } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { userDiscordIdIsAged } from "~/utils/users";
|
||||
import type { RegClosesAtOption } from "./calendar-constants";
|
||||
|
||||
export const canAddNewEvent = (user: { discordId: string }) =>
|
||||
userDiscordIdIsAged(user);
|
||||
|
||||
export const calendarEventMinDate = () => new Date(Date.UTC(2015, 4, 28));
|
||||
export const calendarEventMaxDate = () => {
|
||||
const result = new Date();
|
||||
|
|
@ -144,3 +144,51 @@ export function closeByWeeks(args: { week: number; year: number }) {
|
|||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface CanEditCalendarEventArgs {
|
||||
user?: Pick<Tables["User"], "id">;
|
||||
event: Pick<Tables["CalendarEvent"], "authorId">;
|
||||
}
|
||||
export function canEditCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
}: CanEditCalendarEventArgs) {
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
return user?.id === event.authorId;
|
||||
}
|
||||
|
||||
export function canDeleteCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
startTime,
|
||||
}: CanEditCalendarEventArgs & { startTime: Date }) {
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
return user?.id === event.authorId && startTime > new Date();
|
||||
}
|
||||
|
||||
interface CanReportCalendarEventWinnersArgs {
|
||||
user?: Pick<Tables["User"], "id">;
|
||||
event: Pick<Tables["CalendarEvent"], "authorId">;
|
||||
startTimes: number[];
|
||||
}
|
||||
export function canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes,
|
||||
}: CanReportCalendarEventWinnersArgs) {
|
||||
return allTruthy([
|
||||
canEditCalendarEvent({ user, event }),
|
||||
eventStartedInThePast(startTimes),
|
||||
]);
|
||||
}
|
||||
|
||||
function eventStartedInThePast(
|
||||
startTimes: CanReportCalendarEventWinnersArgs["startTimes"],
|
||||
) {
|
||||
return startTimes.every(
|
||||
(startTime) =>
|
||||
databaseTimestampToDate(startTime).getTime() < new Date().getTime(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { canReportCalendarEventWinners } from "~/permissions";
|
||||
import { notFoundIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server";
|
||||
import { reportWinnersParamsSchema } from "../calendar-schemas";
|
||||
import { canReportCalendarEventWinners } from "../calendar-utils";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const parsedParams = reportWinnersParamsSchema.parse(params);
|
||||
|
|
|
|||
|
|
@ -5,16 +5,15 @@ import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
|||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import { canEditCalendarEvent } from "~/permissions";
|
||||
import { unauthorizedIfFalsy } from "~/utils/remix.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { tournamentBracketsPage } from "~/utils/urls";
|
||||
import { canAddNewEvent } from "../calendar-utils";
|
||||
import { canEditCalendarEvent } from "../calendar-utils";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const url = new URL(request.url);
|
||||
requireRole(user, "CALENDAR_EVENT_ADDER");
|
||||
|
||||
unauthorizedIfFalsy(canAddNewEvent(user));
|
||||
const url = new URL(request.url);
|
||||
|
||||
const eventWithTournament = async (key: string) => {
|
||||
const eventId = Number(url.searchParams.get(key));
|
||||
|
|
|
|||
|
|
@ -16,11 +16,6 @@ import { Table } from "~/components/Table";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import {
|
||||
canDeleteCalendarEvent,
|
||||
canEditCalendarEvent,
|
||||
canReportCalendarEventWinners,
|
||||
} from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
|
|
@ -34,6 +29,11 @@ import {
|
|||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import {
|
||||
canDeleteCalendarEvent,
|
||||
canEditCalendarEvent,
|
||||
canReportCalendarEventWinners,
|
||||
} from "../calendar-utils";
|
||||
import { Tags } from "../components/Tags";
|
||||
|
||||
import { action } from "../actions/calendar.$id.server";
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { SubmitButton } from "~/components/SubmitButton";
|
|||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import type { CalendarEventTag, Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
|
|
@ -46,11 +45,11 @@ import {
|
|||
datesToRegClosesAt,
|
||||
regClosesAtToDisplayName,
|
||||
} from "../calendar-utils";
|
||||
import { canAddNewEvent } from "../calendar-utils";
|
||||
import { BracketProgressionSelector } from "../components/BracketProgressionSelector";
|
||||
import { Tags } from "../components/Tags";
|
||||
import "~/styles/calendar-new.css";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
import { action } from "../actions/calendar.new.server";
|
||||
|
|
@ -80,10 +79,11 @@ const useBaseEvent = () => {
|
|||
|
||||
export default function CalendarNewEventPage() {
|
||||
const baseEvent = useBaseEvent();
|
||||
const user = useUser();
|
||||
const isCalendarEventAdder = useHasRole("CALENDAR_EVENT_ADDER");
|
||||
const isTournamentAdder = useHasRole("TOURNAMENT_ADDER");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (!user || !canAddNewEvent(user)) {
|
||||
if (!isCalendarEventAdder) {
|
||||
return (
|
||||
<Main className="stack items-center">
|
||||
<Alert variation="WARNING">
|
||||
|
|
@ -93,7 +93,7 @@ export default function CalendarNewEventPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (data.isAddingTournament && !user.isTournamentOrganizer) {
|
||||
if (data.isAddingTournament && !isTournamentAdder) {
|
||||
return (
|
||||
<Main className="stack items-center">
|
||||
<Alert variation="WARNING">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
|
|
@ -14,14 +13,14 @@ import { validateImage } from "../queries/validateImage";
|
|||
import { validateImageSchema } from "../upload-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
schema: validateImageSchema,
|
||||
request,
|
||||
});
|
||||
|
||||
errorToastIfFalsy(isMod(user), "Only admins can validate images");
|
||||
|
||||
switch (data._action) {
|
||||
case "VALIDATE": {
|
||||
for (const imageId of data.imageIds) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { countAllUnvalidatedImg } from "../queries/countAllUnvalidatedImg.server";
|
||||
import { unvalidatedImages } from "../queries/unvalidatedImages";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
|
||||
notFoundIfFalsy(isMod(user));
|
||||
const user = await requireUser(request);
|
||||
requireRole(user, "STAFF");
|
||||
|
||||
return {
|
||||
images: unvalidatedImages(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
|
|
@ -17,7 +16,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
const post = posts.find((post) => post.id === data.id);
|
||||
errorToastIfFalsy(post, "Post not found");
|
||||
errorToastIfFalsy(
|
||||
isAdmin(user) || post.author.id === user.id,
|
||||
post.author.id === user.id || user.roles.includes("ADMIN"),
|
||||
"Not your own post",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import {
|
||||
lfgNewPostPage,
|
||||
|
|
@ -49,6 +49,7 @@ export function LFGPost({
|
|||
const USER_POST_EXPANDABLE_CRITERIA = 300;
|
||||
function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
|
||||
const user = useUser();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -75,7 +76,7 @@ function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
|
|||
<div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<PostTextTypeHeader type={post.type} />
|
||||
{isAdmin(user) || post.author.id === user?.id ? (
|
||||
{post.author.id === user?.id || isAdmin ? (
|
||||
<PostDeleteButton id={post.id} type={post.type} />
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -99,6 +100,7 @@ function TeamLFGPost({
|
|||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const user = useUser();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -130,7 +132,7 @@ function TeamLFGPost({
|
|||
<div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<PostTextTypeHeader type={post.type} />
|
||||
{isAdmin(user) || post.author.id === user?.id ? (
|
||||
{post.author.id === user?.id || isAdmin ? (
|
||||
<PostDeleteButton id={post.id} type={post.type} />
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { SubmitButton } from "~/components/SubmitButton";
|
|||
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
LFG_PAGE,
|
||||
|
|
@ -33,7 +34,7 @@ export const handle: SendouRouteHandle = {
|
|||
};
|
||||
|
||||
export default function LFGNewPostPage() {
|
||||
const user = useUser();
|
||||
const isPlusServerMember = useHasRole("PLUS_SERVER_MEMBER");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["common", "lfg"]);
|
||||
|
|
@ -67,7 +68,7 @@ export default function LFGNewPostPage() {
|
|||
/>
|
||||
<TimezoneSelect />
|
||||
<Textarea />
|
||||
{user?.plusTier && type !== "COACH_FOR_TEAM" ? (
|
||||
{isPlusServerMember && type !== "COACH_FOR_TEAM" ? (
|
||||
<PlusVisibilitySelect />
|
||||
) : null}
|
||||
<Languages />
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { canAddCommentToSuggestionBE } from "~/permissions";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
} from "~/utils/remix.server";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { followUpCommentActionSchema } from "../plus-suggestions-schemas";
|
||||
import { canAddCommentToSuggestionBE } from "../plus-suggestions-utils";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const data = await parseRequestPayload({
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { canSuggestNewUserBE } from "~/permissions";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
|
|
@ -16,6 +15,7 @@ import {
|
|||
} from "~/utils/remix.server";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { firstCommentActionSchema } from "../plus-suggestions-schemas";
|
||||
import { canSuggestNewUserBE } from "../plus-suggestions-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestPayload({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { canDeleteComment, isFirstSuggestion } from "~/permissions";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
|
|
@ -15,6 +14,7 @@ import {
|
|||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { suggestionActionSchema } from "../plus-suggestions-schemas";
|
||||
import { canDeleteComment, isFirstSuggestion } from "../plus-suggestions-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestPayload({
|
||||
|
|
|
|||
|
|
@ -1,40 +1,9 @@
|
|||
import type { Tables, UserWithPlusTier } from "~/db/tables";
|
||||
import type * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { ADMIN_ID, LOHI_TOKEN_HEADER_NAME, MOD_IDS } from "./constants";
|
||||
import { currentSeason, nextSeason } from "./features/mmr/season";
|
||||
import { isVotingActive } from "./features/plus-voting/core";
|
||||
import type { FindMatchById } from "./features/tournament-bracket/queries/findMatchById.server";
|
||||
import { allTruthy } from "./utils/arrays";
|
||||
import { databaseTimestampToDate } from "./utils/dates";
|
||||
|
||||
// TODO: move to permissions module and generalize a lot of the logic
|
||||
|
||||
type IsAdminUser = Pick<Tables["User"], "id">;
|
||||
export function isAdmin(user?: IsAdminUser) {
|
||||
return user?.id === ADMIN_ID;
|
||||
}
|
||||
|
||||
export function isMod(user?: IsAdminUser) {
|
||||
if (!user) return false;
|
||||
|
||||
return isAdmin(user) || MOD_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function canPerformAdminActions(user?: IsAdminUser) {
|
||||
if (["development", "test"].includes(process.env.NODE_ENV)) return true;
|
||||
|
||||
return isAdmin(user);
|
||||
}
|
||||
|
||||
function adminOverride(user?: IsAdminUser) {
|
||||
if (isAdmin(user)) {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
return (canPerformActionAsNormalUser: boolean) =>
|
||||
canPerformActionAsNormalUser;
|
||||
}
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { allTruthy } from "~/utils/arrays";
|
||||
import { currentSeason, nextSeason } from "../mmr/season";
|
||||
import { isVotingActive } from "../plus-voting/core";
|
||||
|
||||
interface CanAddCommentToSuggestionArgs {
|
||||
user?: Pick<UserWithPlusTier, "id" | "plusTier">;
|
||||
|
|
@ -82,10 +51,9 @@ export function canDeleteComment(args: CanDeleteCommentArgs) {
|
|||
|
||||
if (isFirstSuggestion(args)) {
|
||||
if (votingActive) return false;
|
||||
if (isAdmin(args.user)) return true;
|
||||
|
||||
return adminOverride(args.user)(
|
||||
allTruthy([isOwnComment(args), suggestionHasNoOtherComments(args)]),
|
||||
);
|
||||
return allTruthy([isOwnComment(args), suggestionHasNoOtherComments(args)]);
|
||||
}
|
||||
|
||||
return isOwnComment(args);
|
||||
|
|
@ -221,101 +189,3 @@ function hasUserSuggestedThisMonth({
|
|||
(suggestion) => suggestion.suggestions[0].author.id === user?.id,
|
||||
);
|
||||
}
|
||||
|
||||
/** Some endpoints can only be accessed with an auth token. Used by Lohi bot and cron jobs. */
|
||||
export function canAccessLohiEndpoint(request: Request) {
|
||||
invariant(process.env.LOHI_TOKEN, "LOHI_TOKEN is required");
|
||||
return request.headers.get(LOHI_TOKEN_HEADER_NAME) === process.env.LOHI_TOKEN;
|
||||
}
|
||||
|
||||
interface CanEditBadgeOwnersArgs {
|
||||
user?: Pick<Tables["User"], "id">;
|
||||
managers: { id: number }[];
|
||||
}
|
||||
|
||||
export function canEditBadgeOwners({ user, managers }: CanEditBadgeOwnersArgs) {
|
||||
return adminOverride(user)(isBadgeManager({ user, managers }));
|
||||
}
|
||||
|
||||
function isBadgeManager({
|
||||
user,
|
||||
managers,
|
||||
}: Pick<CanEditBadgeOwnersArgs, "user" | "managers">) {
|
||||
if (!user) return false;
|
||||
return managers.some((manager) => manager.id === user.id);
|
||||
}
|
||||
|
||||
export function canEditBadgeManagers(user?: IsAdminUser) {
|
||||
return isMod(user);
|
||||
}
|
||||
|
||||
interface CanEditCalendarEventArgs {
|
||||
user?: Pick<Tables["User"], "id">;
|
||||
event: Pick<Tables["CalendarEvent"], "authorId">;
|
||||
}
|
||||
export function canEditCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
}: CanEditCalendarEventArgs) {
|
||||
return adminOverride(user)(user?.id === event.authorId);
|
||||
}
|
||||
|
||||
export function canDeleteCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
startTime,
|
||||
}: CanEditCalendarEventArgs & { startTime: Date }) {
|
||||
return adminOverride(user)(
|
||||
user?.id === event.authorId && startTime.getTime() > new Date().getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
interface CanReportCalendarEventWinnersArgs {
|
||||
user?: Pick<Tables["User"], "id">;
|
||||
event: Pick<Tables["CalendarEvent"], "authorId">;
|
||||
startTimes: number[];
|
||||
}
|
||||
export function canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes,
|
||||
}: CanReportCalendarEventWinnersArgs) {
|
||||
return allTruthy([
|
||||
canEditCalendarEvent({ user, event }),
|
||||
eventStartedInThePast(startTimes),
|
||||
]);
|
||||
}
|
||||
|
||||
function eventStartedInThePast(
|
||||
startTimes: CanReportCalendarEventWinnersArgs["startTimes"],
|
||||
) {
|
||||
return startTimes.every(
|
||||
(startTime) =>
|
||||
databaseTimestampToDate(startTime).getTime() < new Date().getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function canReportTournamentScore({
|
||||
match,
|
||||
isMemberOfATeamInTheMatch,
|
||||
isOrganizer,
|
||||
}: {
|
||||
match: NonNullable<FindMatchById>;
|
||||
isMemberOfATeamInTheMatch: boolean;
|
||||
isOrganizer: boolean;
|
||||
}) {
|
||||
const matchIsOver =
|
||||
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
|
||||
|
||||
return !matchIsOver && (isMemberOfATeamInTheMatch || isOrganizer);
|
||||
}
|
||||
|
||||
export function canAddCustomizedColorsToUserProfile(
|
||||
user?: Pick<Tables["User"], "id" | "patronTier">,
|
||||
) {
|
||||
if (!user) return false;
|
||||
|
||||
return adminOverride(user)(
|
||||
Boolean(user?.patronTier) && user.patronTier! >= 2,
|
||||
);
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@ import { Dialog } from "~/components/Dialog";
|
|||
import { Redirect } from "~/components/Redirect";
|
||||
import { PlUS_SUGGESTION_COMMENT_MAX_LENGTH } from "~/constants";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { canAddCommentToSuggestionFE } from "~/permissions";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { canAddCommentToSuggestionFE } from "../plus-suggestions-utils";
|
||||
import type { PlusSuggestionsLoaderData } from "./plus.suggestions";
|
||||
import { CommentTextarea } from "./plus.suggestions.new";
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ import {
|
|||
} from "~/constants";
|
||||
import type { UserWithPlusTier } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import {
|
||||
canSuggestNewUserFE,
|
||||
playerAlreadyMember,
|
||||
playerAlreadySuggested,
|
||||
} from "~/permissions";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
} from "../plus-suggestions-utils";
|
||||
import type { PlusSuggestionsLoaderData } from "./plus.suggestions";
|
||||
|
||||
import { action } from "../actions/plus.suggestions.new.server";
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ import {
|
|||
isVotingActive,
|
||||
nextNonCompletedVoting,
|
||||
} from "~/features/plus-voting/core";
|
||||
import {
|
||||
canAddCommentToSuggestionFE,
|
||||
canDeleteComment,
|
||||
canSuggestNewUserFE,
|
||||
} from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import {
|
||||
canAddCommentToSuggestionFE,
|
||||
canDeleteComment,
|
||||
canSuggestNewUserFE,
|
||||
} from "../plus-suggestions-utils";
|
||||
|
||||
import { action } from "../actions/plus.suggestions.server";
|
||||
import { loader } from "../loaders/plus.suggestions.server";
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import type { UserWithPlusTier } from "~/db/tables";
|
|||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import { lastCompletedVoting } from "~/features/plus-voting/core";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
|
|
@ -48,8 +48,7 @@ function ownScores({
|
|||
})
|
||||
.map((result) => {
|
||||
const showScore =
|
||||
(result.wasSuggested && !result.passedVoting) ||
|
||||
isAtLeastFiveDollarTierPatreon(user);
|
||||
(result.wasSuggested && !result.passedVoting) || isSupporter(user);
|
||||
|
||||
const resultsOfOwnTierExcludingOwn = () => {
|
||||
const ownTierResults = results.find(
|
||||
|
|
@ -81,7 +80,7 @@ function ownScores({
|
|||
};
|
||||
|
||||
if (!showScore) mappedResult.score = undefined;
|
||||
if (!isAtLeastFiveDollarTierPatreon(user) || !result.passedVoting) {
|
||||
if (!isSupporter(user) || !result.passedVoting) {
|
||||
mappedResult.betterThan = undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { json } from "@remix-run/node";
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { canAccessLohiEndpoint } from "~/permissions";
|
||||
import { canAccessLohiEndpoint } from "~/utils/remix.server";
|
||||
|
||||
export interface PlusListLoaderData {
|
||||
users: Record<string, number>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isMod } from "../../../permissions";
|
||||
import { notFoundIfFalsy } from "../../../utils/remix.server";
|
||||
import { requireUser } from "../../auth/core/user.server";
|
||||
import * as ScrimPostRepository from "../ScrimPostRepository.server";
|
||||
|
|
@ -20,7 +19,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
if (!Scrim.isParticipating(post, user.id) && !isMod(user)) {
|
||||
if (!Scrim.isParticipating(post, user.id) && !user.roles.includes("STAFF")) {
|
||||
throw new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.ser
|
|||
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { findCurrentGroupByUserId } from "~/features/sendouq/queries/findCurrentGroupByUserId.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
|
|
@ -80,7 +79,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
}
|
||||
|
||||
errorToastIfFalsy(
|
||||
!data.adminReport || isMod(user),
|
||||
!data.adminReport || user.roles.includes("STAFF"),
|
||||
"Only mods can report scores as admin",
|
||||
);
|
||||
const members = [
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import cachified from "@epic-web/cachified";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { reportedWeaponsToArrayOfArrays } from "~/features/sendouq-match/core/reported-weapons.server";
|
||||
import { reportedWeaponsByMatchId } from "~/features/sendouq-match/queries/reportedWeaponsByMatchId.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
|
@ -12,7 +11,7 @@ import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
|||
import { qMatchPageParamsSchema } from "../q-match-schemas";
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const user = await getUser(request);
|
||||
const matchId = parseParams({
|
||||
params,
|
||||
schema: qMatchPageParamsSchema,
|
||||
|
|
@ -34,7 +33,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
|
||||
const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id);
|
||||
const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id);
|
||||
const isMatchInsider = isTeamAlphaMember || isTeamBravoMember || isMod(user);
|
||||
const isMatchInsider =
|
||||
isTeamAlphaMember || isTeamBravoMember || user?.roles.includes("STAFF");
|
||||
const matchHappenedInTheLastMonth =
|
||||
databaseTimestampToDate(match.createdAt).getTime() >
|
||||
Date.now() - 30 * 24 * 3600 * 1000;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import { useIsMounted } from "~/hooks/useIsMounted";
|
|||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { isMod } from "~/permissions";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { animate } from "~/utils/flip";
|
||||
|
|
@ -100,6 +100,7 @@ export const handle: SendouRouteHandle = {
|
|||
|
||||
export default function QMatchPage() {
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const isMounted = useIsMounted();
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
@ -116,7 +117,7 @@ export default function QMatchPage() {
|
|||
data.groupAlpha.members.find((m) => m.id === user?.id) ??
|
||||
data.groupBravo.members.find((m) => m.id === user?.id);
|
||||
const canReportScore = Boolean(
|
||||
!data.match.isLocked && (ownMember || isMod(user)),
|
||||
!data.match.isLocked && (ownMember || isStaff),
|
||||
);
|
||||
|
||||
const ownGroup = data.groupAlpha.members.some((m) => m.id === user?.id)
|
||||
|
|
@ -648,6 +649,7 @@ function BottomSection({
|
|||
const [isReportingWeapons, setIsReportingWeapons] = React.useState(false);
|
||||
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const submitScoreFetcher = useFetcher<typeof action>();
|
||||
const cancelFetcher = useFetcher<typeof action>();
|
||||
|
|
@ -698,8 +700,7 @@ function BottomSection({
|
|||
|
||||
const unseenMessages = chatVisible ? 0 : _unseenMessages;
|
||||
|
||||
const showMid =
|
||||
!data.match.isLocked && (participatingInTheMatch || isMod(user));
|
||||
const showMid = !data.match.isLocked && (participatingInTheMatch || isStaff);
|
||||
|
||||
const poolCode = () => {
|
||||
const stringId = String(data.match.id);
|
||||
|
|
@ -941,6 +942,7 @@ function MapList({
|
|||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [adminToggleChecked, setAdminToggleChecked] = React.useState(false);
|
||||
const [ownWeaponsUsage, setOwnWeaponsUsage] = React.useState<
|
||||
|
|
@ -1022,7 +1024,7 @@ function MapList({
|
|||
})}
|
||||
</div>
|
||||
</Flipper>
|
||||
{scoreCanBeReported && isMod(user) ? (
|
||||
{scoreCanBeReported && isStaff ? (
|
||||
<div className="stack sm horizontal items-center text-sm font-semi-bold">
|
||||
<SendouSwitch
|
||||
name="adminReport"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { userSkills } from "~/features/mmr/tiered.server";
|
|||
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import { hasGroupManagerPerms } from "../core/groups";
|
||||
import {
|
||||
addFutureMatchModes,
|
||||
|
|
@ -33,8 +32,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
|
||||
const isPreview = Boolean(
|
||||
new URL(request.url).searchParams.get("preview") === "true" &&
|
||||
user &&
|
||||
isAtLeastFiveDollarTierPatreon(user),
|
||||
user?.roles.includes("SUPPORTER"),
|
||||
);
|
||||
|
||||
const currentGroup =
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import type { RankingSeason } from "~/features/mmr/season";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
|
@ -35,7 +36,6 @@ import {
|
|||
navIconUrl,
|
||||
userSeasonsPage,
|
||||
} from "~/utils/urls";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SendouPopover } from "../../../components/elements/Popover";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
|
|
@ -450,10 +450,10 @@ function NoUpcomingSeasonInfo() {
|
|||
}
|
||||
|
||||
function PreviewQueueButton() {
|
||||
const user = useUser();
|
||||
const isSupporter = useHasRole("SUPPORTER");
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
if (!isAtLeastFiveDollarTierPatreon(user)) {
|
||||
if (!isSupporter) {
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
|
|
@ -14,13 +13,13 @@ import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
|
|||
import { isTeamManager, isTeamOwner } from "../team-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
|
||||
|
||||
errorToastIfFalsy(
|
||||
isTeamManager({ team, user }) || isAdmin(user),
|
||||
isTeamManager({ team, user }) || user.roles.includes("ADMIN"),
|
||||
"You are not a team manager",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
|
|
@ -13,12 +12,12 @@ import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server";
|
|||
import { isTeamManager } from "../team-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
|
||||
errorToastIfFalsy(
|
||||
isTeamManager({ team, user }) || isAdmin(user),
|
||||
isTeamManager({ team, user }) || user.roles.includes("ADMIN"),
|
||||
"Only team manager or owner can manage roster",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { redirect } from "@remix-run/node";
|
|||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { mySlugify, teamPage } from "~/utils/urls";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { TEAM } from "../team-constants";
|
||||
import { createTeamSchema } from "../team-schemas.server";
|
||||
|
|
@ -20,7 +19,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
const currentTeamCount = teams.filter((team) =>
|
||||
team.members.some((m) => m.id === user.id),
|
||||
).length;
|
||||
const maxTeamCount = isAtLeastFiveDollarTierPatreon(user)
|
||||
const maxTeamCount = user.roles.includes("SUPPORTER")
|
||||
? TEAM.MAX_TEAM_COUNT_PATRON
|
||||
: TEAM.MAX_TEAM_COUNT_NON_PATRON;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
|
|
@ -9,12 +8,12 @@ import { teamParamsSchema } from "../team-schemas.server";
|
|||
import { isTeamManager } from "../team-utils";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
|
||||
|
||||
if (!isTeamManager({ team, user }) && !isAdmin(user)) {
|
||||
if (!isTeamManager({ team, user }) && !user.roles.includes("ADMIN")) {
|
||||
throw redirect(teamPage(customUrl));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { isTeamManager } from "../team-utils";
|
||||
|
||||
import "../team.css";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfFalsy(
|
||||
|
|
@ -19,7 +19,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|||
}),
|
||||
);
|
||||
|
||||
if (!isTeamManager({ team, user }) && !isAdmin(user)) {
|
||||
if (!isTeamManager({ team, user }) && !user.roles.includes("ADMIN")) {
|
||||
throw redirect(teamPage(customUrl));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { EditIcon } from "~/components/icons/Edit";
|
|||
import { StarIcon } from "~/components/icons/Star";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
TEAM_SEARCH_PAGE,
|
||||
|
|
@ -36,6 +35,7 @@ import {
|
|||
resolveNewOwner,
|
||||
} from "../team-utils";
|
||||
import "../team.css";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
import { action } from "../actions/t.$customUrl.server";
|
||||
|
|
@ -191,9 +191,10 @@ function BskyLink() {
|
|||
function ActionButtons() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
const user = useUser();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
if (!isTeamMember({ user, team }) && !isAdmin(user)) {
|
||||
if (!isTeamMember({ user, team }) && !isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +230,7 @@ function ActionButtons() {
|
|||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
{isTeamManager({ user, team }) || isAdmin(user) ? (
|
||||
{isTeamManager({ user, team }) || isAdmin ? (
|
||||
<LinkButton
|
||||
size="tiny"
|
||||
to={manageTeamRosterPage(team.customUrl)}
|
||||
|
|
@ -241,7 +242,7 @@ function ActionButtons() {
|
|||
{t("team:actionButtons.manageRoster")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
{isTeamManager({ user, team }) || isAdmin(user) ? (
|
||||
{isTeamManager({ user, team }) || isAdmin ? (
|
||||
<LinkButton
|
||||
size="tiny"
|
||||
to={editTeamPage(team.customUrl)}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { SubmitButton } from "~/components/SubmitButton";
|
|||
import { SearchIcon } from "~/components/icons/Search";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { usePagination } from "~/hooks/usePagination";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
|
@ -28,7 +29,6 @@ import {
|
|||
teamPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import { TEAM, TEAMS_PER_PAGE } from "../team-constants";
|
||||
|
||||
import { action } from "../actions/t.server";
|
||||
|
|
@ -160,6 +160,7 @@ function NewTeamDialog() {
|
|||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const user = useUser();
|
||||
const isSupporter = useHasRole("SUPPORTER");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const isOpen = searchParams.get("new") === "true";
|
||||
|
|
@ -168,8 +169,7 @@ function NewTeamDialog() {
|
|||
|
||||
const canAddNewTeam = () => {
|
||||
if (!user) return false;
|
||||
|
||||
if (isAtLeastFiveDollarTierPatreon(user)) {
|
||||
if (isSupporter) {
|
||||
return data.teamMemberOfCount < TEAM.MAX_TEAM_COUNT_PATRON;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { requireUser } from "~/features/auth/core/user.server";
|
|||
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { canReportTournamentScore } from "~/permissions";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
|
|
@ -27,7 +26,10 @@ import { deleteMatchPickBanEvents } from "../queries/deleteMatchPickBanEvents.se
|
|||
import { deleteParticipantsByMatchGameResultId } from "../queries/deleteParticipantsByMatchGameResultId.server";
|
||||
import { deletePickBanEvent } from "../queries/deletePickBanEvent.server";
|
||||
import { deleteTournamentMatchGameResultById } from "../queries/deleteTournamentMatchGameResultById.server";
|
||||
import { findMatchById } from "../queries/findMatchById.server";
|
||||
import {
|
||||
type FindMatchById,
|
||||
findMatchById,
|
||||
} from "../queries/findMatchById.server";
|
||||
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
|
||||
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
|
||||
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
|
||||
|
|
@ -577,3 +579,18 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
|
||||
return null;
|
||||
};
|
||||
|
||||
function canReportTournamentScore({
|
||||
match,
|
||||
isMemberOfATeamInTheMatch,
|
||||
isOrganizer,
|
||||
}: {
|
||||
match: NonNullable<FindMatchById>;
|
||||
isMemberOfATeamInTheMatch: boolean;
|
||||
isOrganizer: boolean;
|
||||
}) {
|
||||
const matchIsOver =
|
||||
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
|
||||
|
||||
return !matchIsOver && (isMemberOfATeamInTheMatch || isOrganizer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
import { Tournament } from "./Tournament";
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"
|
|||
import type { Match, Stage } from "~/modules/brackets-model";
|
||||
import type { ModeShort } from "~/modules/in-game-lists";
|
||||
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import {
|
||||
databaseTimestampNow,
|
||||
databaseTimestampToDate,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { isAdmin } from "~/permissions";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import type { UnwrappedNonNullable } from "~/utils/types";
|
||||
import type * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
|
|||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
|
|
@ -32,7 +31,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
tournament.ctx.staff.some(
|
||||
(s) => s.role === "ORGANIZER" && s.id === user?.id,
|
||||
) ||
|
||||
isAdmin(user) ||
|
||||
user?.roles.includes("ADMIN") ||
|
||||
tournament.ctx.organization?.members.some(
|
||||
(m) => m.userId === user?.id && m.role === "ADMIN",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
UserPreferences,
|
||||
} from "~/db/tables";
|
||||
import type { ChatUser } from "~/features/chat/components/Chat";
|
||||
import { userRoles } from "~/modules/permissions/mapper.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
|
|
@ -271,14 +272,8 @@ export function findBannedStatusByUserId(userId: number) {
|
|||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
const userIsTournamentOrganizer = sql<
|
||||
string | null
|
||||
>`IIF(COALESCE("User"."patronTier", 0) >= 2, 1, "User"."isTournamentOrganizer")`.as(
|
||||
"isTournamentOrganizer",
|
||||
);
|
||||
|
||||
export function findLeanById(id: number) {
|
||||
return db
|
||||
export async function findLeanById(id: number) {
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.where("User.id", "=", id)
|
||||
|
|
@ -286,7 +281,7 @@ export function findLeanById(id: number) {
|
|||
...COMMON_USER_FIELDS,
|
||||
"User.isArtist",
|
||||
"User.isVideoAdder",
|
||||
userIsTournamentOrganizer,
|
||||
"User.isTournamentOrganizer",
|
||||
"User.patronTier",
|
||||
"User.favoriteBadgeId",
|
||||
"User.languages",
|
||||
|
|
@ -302,6 +297,13 @@ export function findLeanById(id: number) {
|
|||
.as("friendCode"),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) return;
|
||||
|
||||
return {
|
||||
...user,
|
||||
roles: userRoles(user),
|
||||
};
|
||||
}
|
||||
|
||||
export function findAllPatrons() {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { userIsBanned } from "~/features/ban/core/banned.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const loggedInUser = await getUserId(request);
|
||||
const loggedInUser = await getUser(request);
|
||||
|
||||
const user = notFoundIfFalsy(
|
||||
await UserRepository.findProfileByIdentifier(params.identifier!),
|
||||
|
|
@ -15,7 +14,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
return {
|
||||
user,
|
||||
banned:
|
||||
isAdmin(loggedInUser) && userIsBanned(user.id)
|
||||
loggedInUser?.roles.includes("ADMIN") && userIsBanned(user.id)
|
||||
? await UserRepository.findBannedStatusByUserId(user.id)!
|
||||
: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { redirect } from "react-router-dom";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
|
||||
export const loader: LoaderFunction = async ({ params }) => {
|
||||
const user = await UserRepository.findByCustomUrl(params.customUrl!);
|
||||
|
||||
if (!user || !isAtLeastFiveDollarTierPatreon(user)) {
|
||||
if (!user || !isSupporter(user)) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,8 @@ import { StarFilledIcon } from "~/components/icons/StarFilled";
|
|||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { USER } from "~/constants";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import { canAddCustomizedColorsToUserProfile } from "~/permissions";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { rawSensToString } from "~/utils/strings";
|
||||
import { FAQ_PAGE } from "~/utils/urls";
|
||||
|
|
@ -32,17 +31,19 @@ export { loader, action };
|
|||
import "~/styles/u-edit.css";
|
||||
|
||||
export default function UserEditPage() {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["common", "user"]);
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.data as UserPageLoaderData;
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const isSupporter = useHasRole("SUPPORTER");
|
||||
const isArtist = useHasRole("ARTIST");
|
||||
|
||||
return (
|
||||
<div className="half-width">
|
||||
<Form className="u-edit__container" method="post">
|
||||
{canAddCustomizedColorsToUserProfile(user) ? (
|
||||
{isSupporter ? (
|
||||
<CustomizedColorsInput initialColors={layoutData.css} />
|
||||
) : null}
|
||||
<CustomNameInput />
|
||||
|
|
@ -59,7 +60,7 @@ export default function UserEditPage() {
|
|||
) : (
|
||||
<input type="hidden" name="showDiscordUniqueName" value="on" />
|
||||
)}
|
||||
{user?.isArtist ? (
|
||||
{isArtist ? (
|
||||
<>
|
||||
<CommissionsOpenToggle parentRouteData={layoutData} />
|
||||
<CommissionTextArea initialValue={layoutData.user.commissionText} />
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ import {
|
|||
modesShort,
|
||||
stageIds,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { Alert } from "../../../components/Alert";
|
||||
import { DateFormField } from "../../../components/form/DateFormField";
|
||||
import { MyForm } from "../../../components/form/MyForm";
|
||||
import { SelectFormField } from "../../../components/form/SelectFormField";
|
||||
import { TextFormField } from "../../../components/form/TextFormField";
|
||||
import { useUser } from "../../auth/core/user";
|
||||
import { videoMatchTypes } from "../vods-constants";
|
||||
import { videoInputSchema } from "../vods-schemas";
|
||||
|
||||
|
|
@ -43,11 +43,11 @@ export const handle: SendouRouteHandle = {
|
|||
export type VodFormFields = z.infer<typeof videoInputSchema>;
|
||||
|
||||
export default function NewVodPage() {
|
||||
const user = useUser();
|
||||
const isVideoAdder = useHasRole("VIDEO_ADDER");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["vods"]);
|
||||
|
||||
if (!user || !user.isVideoAdder) {
|
||||
if (!isVideoAdder) {
|
||||
return (
|
||||
<Main className="stack items-center">
|
||||
<Alert variation="WARNING">{t("vods:gainPerms")}</Alert>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { databaseTimestampToDate } from "../../utils/dates";
|
||||
import { HOURS_MINUTES_SECONDS_REGEX } from "./vods-schemas";
|
||||
import type { VideoBeingAdded, Vod } from "./vods-types";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { z } from "zod";
|
||||
import { MOD_DISCORD_IDS } from "~/constants";
|
||||
import { STAFF_DISCORD_IDS } from "~/constants";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { fetchWithTimeout } from "~/utils/fetch";
|
||||
|
|
@ -35,7 +35,7 @@ export async function updatePatreonData(): Promise<void> {
|
|||
|
||||
const patronsWithMods: UserRepository.UpdatePatronDataArgs = [
|
||||
...patrons,
|
||||
...MOD_DISCORD_IDS.filter((discordId) =>
|
||||
...STAFF_DISCORD_IDS.filter((discordId) =>
|
||||
patrons.every((p) => p.discordId !== discordId),
|
||||
).map((discordId) => ({
|
||||
discordId,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
import type { EntityWithPermissions } from "~/modules/permissions/types";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
|
||||
import { isAdmin } from "./utils";
|
||||
|
||||
// TODO: could avoid passing user in after remix middlewares land with async context
|
||||
|
||||
/**
|
||||
* Checks if a user has the required global role.
|
||||
*
|
||||
* @throws {Response} - Throws a 403 Forbidden response if the user does not have the required role.
|
||||
*/
|
||||
export function requireRole(user: { roles: Array<Role> }, role: Role) {
|
||||
if (!user.roles.includes(role)) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the required permission to perform an action on a given entity.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { EntityWithPermissions } from "~/modules/permissions/types";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
|
||||
import { isAdmin } from "./utils";
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific global role.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified role. Always false if user is not logged in.
|
||||
*/
|
||||
export function useHasRole(role: Role) {
|
||||
const user = useUser();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
return user.roles.includes(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific permission for a given entity.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified permission.
|
||||
* @returns A boolean indicating whether the user has the specified permission. Always false if user is not logged in.
|
||||
*/
|
||||
export function useHasPermission<
|
||||
T extends EntityWithPermissions,
|
||||
|
|
|
|||
57
app/modules/permissions/mapper.server.ts
Normal file
57
app/modules/permissions/mapper.server.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { UserWithPlusTier } from "~/db/tables";
|
||||
import { userDiscordIdIsAged } from "~/utils/users";
|
||||
import type { Role } from "./types";
|
||||
import { isAdmin, isStaff, isSupporter } from "./utils";
|
||||
|
||||
export function userRoles(
|
||||
user: Pick<
|
||||
UserWithPlusTier,
|
||||
| "id"
|
||||
| "discordId"
|
||||
| "plusTier"
|
||||
| "isArtist"
|
||||
| "isTournamentOrganizer"
|
||||
| "isVideoAdder"
|
||||
| "patronTier"
|
||||
>,
|
||||
) {
|
||||
const result: Array<Role> = [];
|
||||
|
||||
if (isAdmin(user)) {
|
||||
result.push("ADMIN");
|
||||
}
|
||||
|
||||
if (isStaff(user) || isAdmin(user)) {
|
||||
result.push("STAFF");
|
||||
}
|
||||
|
||||
if (typeof user.patronTier === "number") {
|
||||
result.push("MINOR_SUPPORT");
|
||||
}
|
||||
|
||||
if (isSupporter(user)) {
|
||||
result.push("SUPPORTER");
|
||||
}
|
||||
|
||||
if (typeof user.plusTier === "number") {
|
||||
result.push("PLUS_SERVER_MEMBER");
|
||||
}
|
||||
|
||||
if (user.isArtist) {
|
||||
result.push("ARTIST");
|
||||
}
|
||||
|
||||
if (user.isVideoAdder) {
|
||||
result.push("VIDEO_ADDER");
|
||||
}
|
||||
|
||||
if (user.isTournamentOrganizer || isSupporter(user)) {
|
||||
result.push("TOURNAMENT_ADDER");
|
||||
}
|
||||
|
||||
if (userDiscordIdIsAged(user)) {
|
||||
result.push("CALENDAR_EVENT_ADDER");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -3,3 +3,15 @@ export type Permissions = Record<string, number[]>;
|
|||
export type EntityWithPermissions = {
|
||||
permissions: Permissions;
|
||||
};
|
||||
|
||||
/** Represents a "global" role with permissions associated to it */
|
||||
export type Role =
|
||||
| "ADMIN"
|
||||
| "STAFF"
|
||||
| "PLUS_SERVER_MEMBER"
|
||||
| "VIDEO_ADDER"
|
||||
| "ARTIST"
|
||||
| "CALENDAR_EVENT_ADDER"
|
||||
| "TOURNAMENT_ADDER"
|
||||
| "SUPPORTER" // patrons of "Supporter" tier or higher
|
||||
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher
|
||||
|
|
|
|||
15
app/modules/permissions/utils.ts
Normal file
15
app/modules/permissions/utils.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { ADMIN_ID, STAFF_IDS } from "~/constants";
|
||||
|
||||
export function isAdmin(user?: { id: number }) {
|
||||
return user?.id === ADMIN_ID;
|
||||
}
|
||||
|
||||
export function isStaff(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return STAFF_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isSupporter(user?: { patronTier: number | null }) {
|
||||
return typeof user?.patronTier === "number" && user.patronTier >= 2;
|
||||
}
|
||||
|
|
@ -104,16 +104,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
id: user.id,
|
||||
plusTier: user.plusTier,
|
||||
customUrl: user.customUrl,
|
||||
patronTier: user.patronTier,
|
||||
isArtist: user.isArtist,
|
||||
isVideoAdder: user.isVideoAdder,
|
||||
isTournamentOrganizer: user.isTournamentOrganizer,
|
||||
inGameName: user.inGameName,
|
||||
friendCode: user.friendCode,
|
||||
preferences: user.preferences ?? {},
|
||||
languages: user.languages ? user.languages.split(",") : [],
|
||||
plusTier: user.plusTier,
|
||||
roles: user.roles,
|
||||
}
|
||||
: undefined,
|
||||
notifications: user
|
||||
|
|
@ -519,7 +516,7 @@ function PWALinks() {
|
|||
}
|
||||
|
||||
function MyRamp({ data }: { data: RootLoaderData | undefined }) {
|
||||
if (!data || data.user?.patronTier) {
|
||||
if (!data || !data.user?.roles.includes("MINOR_SUPPORT")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { Namespace, TFunction } from "i18next";
|
|||
import { nanoid } from "nanoid";
|
||||
import type { z } from "zod";
|
||||
import type { navItems } from "~/components/layout/nav-items";
|
||||
import { LOHI_TOKEN_HEADER_NAME } from "~/constants";
|
||||
import { s3UploadHandler } from "~/features/img-upload";
|
||||
import invariant from "./invariant";
|
||||
import { logger } from "./logger";
|
||||
|
|
@ -181,6 +182,12 @@ function formDataToObject(formData: FormData) {
|
|||
return result;
|
||||
}
|
||||
|
||||
/** Some endpoints can only be accessed with an auth token. Used by Lohi bot and cron jobs. */
|
||||
export function canAccessLohiEndpoint(request: Request) {
|
||||
invariant(process.env.LOHI_TOKEN, "LOHI_TOKEN is required");
|
||||
return request.headers.get(LOHI_TOKEN_HEADER_NAME) === process.env.LOHI_TOKEN;
|
||||
}
|
||||
|
||||
// TODO: investigate better solution to toasts when middlewares land (current one has a problem of clearing search params)
|
||||
|
||||
export function errorToastRedirect(message: string) {
|
||||
|
|
|
|||
|
|
@ -59,11 +59,11 @@ describe("userDiscordIdIsAged()", () => {
|
|||
expect(userDiscordIdIsAged({ discordId: "79237403620945920" })).toBe(true);
|
||||
});
|
||||
|
||||
test("throws error if discord id missing", () => {
|
||||
expect(() => userDiscordIdIsAged({ discordId: "" })).toThrow();
|
||||
test("return false if discord id missing", () => {
|
||||
expect(userDiscordIdIsAged({ discordId: "" })).toBe(false);
|
||||
});
|
||||
|
||||
test("throws error if discord id too short", () => {
|
||||
expect(() => userDiscordIdIsAged({ discordId: "1234" })).toThrow();
|
||||
test("return false if discord id too short", () => {
|
||||
expect(userDiscordIdIsAged({ discordId: "1234" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { logger } from "./logger";
|
||||
import { isCustomUrl } from "./urls";
|
||||
|
||||
export function isAtLeastFiveDollarTierPatreon(
|
||||
user?: Pick<Tables["User"], "patronTier" | "id">,
|
||||
) {
|
||||
if (!user) return false;
|
||||
|
||||
return isAdmin(user) || (user.patronTier && user.patronTier >= 2);
|
||||
}
|
||||
|
||||
const longUrlRegExp = /(https:\/\/)?sendou.ink\/u\/(.+)/;
|
||||
const shortUrlRegExp = /(https:\/\/)?snd.ink\/(.+)/;
|
||||
const DISCORD_ID_MIN_LENGTH = 17;
|
||||
|
|
@ -54,13 +45,12 @@ function convertSnowflakeToDate(snowflake: string) {
|
|||
|
||||
const AGED_CRITERIA = 1000 * 60 * 60 * 24 * 30 * 3; // 3 months
|
||||
export function userDiscordIdIsAged(user: { discordId: string }) {
|
||||
// types should catch this but since this is a permission related
|
||||
// code playing it safe
|
||||
if (!user.discordId) {
|
||||
throw new Error("No discord id");
|
||||
}
|
||||
if (user.discordId.length < DISCORD_ID_MIN_LENGTH) {
|
||||
throw new Error("Not a valid discord id");
|
||||
if (!user.discordId || user.discordId.length < DISCORD_ID_MIN_LENGTH) {
|
||||
logger.error("Invalid or missing discord id", {
|
||||
discordId: user.discordId,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamp = convertSnowflakeToDate(user.discordId).getTime();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user