Global roles refactor (#2212)
Some checks failed
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

* 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:
Kalle 2025-04-21 23:51:30 +03:00 committed by GitHub
parent d28f348629
commit d2551d2706
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 525 additions and 491 deletions

View File

@ -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 (

View File

@ -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)}
/>

View File

@ -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";

View File

@ -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,

View File

@ -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

View File

@ -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>
);
}

View File

@ -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 });
}

View File

@ -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)) {

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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(

View File

@ -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,

View File

@ -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 });

View File

@ -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,
};
};

View File

@ -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"];

View File

@ -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", {

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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 = {

View File

@ -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(),
);
}

View File

@ -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);

View File

@ -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));

View File

@ -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";

View File

@ -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">

View File

@ -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) {

View File

@ -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(),

View File

@ -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",
);

View File

@ -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>

View File

@ -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 />

View File

@ -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({

View File

@ -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({

View File

@ -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({

View File

@ -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,
);
}

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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;
}

View File

@ -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>;

View File

@ -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 });
}

View File

@ -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 = [

View File

@ -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;

View File

@ -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"

View File

@ -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 =

View File

@ -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={

View File

@ -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",
);

View File

@ -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",
);

View File

@ -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;

View File

@ -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));
}

View File

@ -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));
}

View File

@ -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)}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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";

View File

@ -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,

View File

@ -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";

View File

@ -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",
);

View File

@ -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() {

View File

@ -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,
};

View File

@ -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("/");
}

View File

@ -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} />

View File

@ -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>

View File

@ -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";

View File

@ -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,

View File

@ -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.
*

View File

@ -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,

View 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;
}

View File

@ -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

View 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;
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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);
});
});

View File

@ -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();