From df3772403e40d49dbd9df0c4631dc385dc1cc349 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 14 Jun 2025 08:41:52 +0300 Subject: [PATCH] User admin tab Closes #2388 --- app/db/tables.ts | 20 ++ app/features/admin/AdminRepository.server.ts | 82 ++++++- app/features/admin/actions/admin.server.ts | 21 +- app/features/sendouq/actions/q.server.ts | 1 + .../user-page/UserRepository.server.ts | 42 ++++ .../actions/u.$identifier.admin.server.ts | 57 +++++ .../actions/u.$identifier.edit.server.ts | 2 +- ...u.$identifier.results.highlights.server.ts | 2 +- .../loaders/u.$identifier.admin.server.ts | 30 +++ .../loaders/u.$identifier.art.server.ts | 2 +- .../loaders/u.$identifier.builds.server.ts | 2 +- .../loaders/u.$identifier.edit.server.ts | 2 +- .../loaders/u.$identifier.index.server.ts | 10 +- .../loaders/u.$identifier.seasons.server.ts | 2 +- .../routes/u.$identifier.admin.module.css | 11 + .../user-page/routes/u.$identifier.admin.tsx | 229 ++++++++++++++++++ .../routes/u.$identifier.edit.test.ts | 2 +- .../user-page/routes/u.$identifier.index.tsx | 39 --- .../user-page/routes/u.$identifier.tsx | 10 +- app/features/user-page/user-page-constants.ts | 1 + ...schemas.server.ts => user-page-schemas.ts} | 16 ++ app/routes.ts | 1 + app/utils/urls.ts | 1 + app/utils/users.ts | 2 +- migrations/089-ban-log.js | 37 +++ 25 files changed, 536 insertions(+), 88 deletions(-) create mode 100644 app/features/user-page/actions/u.$identifier.admin.server.ts create mode 100644 app/features/user-page/loaders/u.$identifier.admin.server.ts create mode 100644 app/features/user-page/routes/u.$identifier.admin.module.css create mode 100644 app/features/user-page/routes/u.$identifier.admin.tsx rename app/features/user-page/{user-page-schemas.server.ts => user-page-schemas.ts} (90%) create mode 100644 migrations/089-ban-log.js diff --git a/app/db/tables.ts b/app/db/tables.ts index 5de29f4c9..07676bf68 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -888,6 +888,24 @@ export interface UserFriendCode { createdAt: GeneratedAlways; } +export interface BanLog { + id: GeneratedAlways; + userId: number; + banned: number | null; + bannedReason: string | null; + bannedByUserId: number; + createdAt: GeneratedAlways; +} + +export interface ModNote { + id: GeneratedAlways; + userId: number; + authorId: number; + text: string; + createdAt: GeneratedAlways; + isDeleted: Generated; +} + export interface Video { eventId: number | null; id: GeneratedAlways; @@ -1039,6 +1057,8 @@ export interface DB { BadgeManager: BadgeManager; BadgeOwner: BadgeOwner; TournamentBadgeOwner: TournamentBadgeOwner; + BanLog: BanLog; + ModNote: ModNote; Build: Build; BuildAbility: BuildAbility; BuildWeapon: BuildWeapon; diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index ec068e990..80697c174 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -1,6 +1,6 @@ import type { Transaction } from "kysely"; import { db, sql } from "~/db/sql"; -import type { DB, Tables } from "~/db/tables"; +import type { DB, Tables, TablesInsertable } from "~/db/tables"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { syncXPBadges } from "../badges/queries/syncXPBadges.server"; @@ -210,25 +210,85 @@ export function banUser({ userId, banned, bannedReason, + bannedByUserId, }: { userId: number; banned: 1 | Date; bannedReason: string | null; + /** Which user banned the user? If null then it means it was an automatic ban. */ + bannedByUserId: number | null; }) { - return db - .updateTable("User") - .set({ + return db.transaction().execute(async (trx) => { + const banArgs = { banned: banned === 1 ? banned : dateToDatabaseTimestamp(banned), bannedReason, - }) - .where("User.id", "=", userId) - .execute(); + }; + + await trx + .updateTable("User") + .set(banArgs) + .where("User.id", "=", userId) + .execute(); + + if (typeof bannedByUserId === "number") { + await trx + .insertInto("BanLog") + .values({ + ...banArgs, + userId, + bannedByUserId, + }) + .execute(); + } + }); } -export function unbanUser(userId: number) { +export function unbanUser({ + userId, + unbannedByUserId, +}: { + userId: number; + unbannedByUserId: number; +}) { + return db.transaction().execute(async (trx) => { + const banArgs = { + banned: 0, + bannedReason: null, + }; + + await trx + .updateTable("User") + .set(banArgs) + .where("User.id", "=", userId) + .execute(); + + await trx + .insertInto("BanLog") + .values({ + ...banArgs, + userId, + bannedByUserId: unbannedByUserId, + }) + .execute(); + }); +} + +export function addModNote(args: TablesInsertable["ModNote"]) { + return db.insertInto("ModNote").values(args).execute(); +} + +export function findModeNoteById(id: number) { return db - .updateTable("User") - .set({ banned: 0 }) - .where("User.id", "=", userId) + .selectFrom("ModNote") + .selectAll() + .where("ModNote.id", "=", id) + .executeTakeFirst(); +} + +export function deleteModNote(id: number) { + return db + .updateTable("ModNote") + .set({ isDeleted: 1 }) + .where("ModNote.id", "=", id) .execute(); } diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index 984fc5ec0..2adad5df4 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -6,7 +6,6 @@ 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 { requireRole } from "~/modules/permissions/guards.server"; -import { logger } from "~/utils/logger"; import { errorToast, parseRequestPayload, @@ -125,34 +124,24 @@ export const action = async ({ request }: ActionFunctionArgs) => { bannedReason: data.reason ?? null, userId: data.user, banned: data.duration ? new Date(data.duration) : 1, + bannedByUserId: user.id, }); refreshBannedCache(); - logger.info("Banned user", { - userId: data.user, - byUserId: user.id, - reason: data.reason, - duration: data.duration - ? new Date(data.duration).toLocaleString() - : undefined, - }); - message = "User banned"; break; } case "UNBAN_USER": { requireRole(user, "STAFF"); - await AdminRepository.unbanUser(data.user); + await AdminRepository.unbanUser({ + userId: data.user, + unbannedByUserId: user.id, + }); refreshBannedCache(); - logger.info("Unbanned user", { - userId: data.user, - byUserId: user.id, - }); - message = "User unbanned"; break; } diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index cc1b36765..f172a05e2 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -110,6 +110,7 @@ export const action: ActionFunction = async ({ request }) => { banned: 1, bannedReason: "[automatic ban] This friend code is already in use by some other account. Please contact staff on our Discord helpdesk for resolution including merging accounts.", + bannedByUserId: null, }); refreshBannedCache(); diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 683d7d95e..7aff2696e 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -339,6 +339,48 @@ export async function findLeanById(id: number) { }; } +export function findModInfoById(id: number) { + return db + .selectFrom("User") + .select((eb) => [ + "User.discordUniqueName", + "User.isVideoAdder", + "User.isArtist", + "User.isTournamentOrganizer", + "User.plusSkippedForSeasonNth", + "User.createdAt", + jsonArrayFrom( + eb + .selectFrom("ModNote") + .innerJoin("User", "User.id", "ModNote.authorId") + .select([ + "ModNote.id as noteId", + "ModNote.text", + "ModNote.createdAt", + ...COMMON_USER_FIELDS, + ]) + .where("ModNote.isDeleted", "=", 0) + .where("ModNote.userId", "=", id) + .orderBy("ModNote.createdAt", "desc"), + ).as("modNotes"), + jsonArrayFrom( + eb + .selectFrom("BanLog") + .innerJoin("User", "User.id", "BanLog.bannedByUserId") + .select([ + "BanLog.banned", + "BanLog.bannedReason", + "BanLog.createdAt", + ...COMMON_USER_FIELDS, + ]) + .where("BanLog.userId", "=", id) + .orderBy("BanLog.createdAt", "desc"), + ).as("banLogs"), + ]) + .where("User.id", "=", id) + .executeTakeFirst(); +} + export function findAllPatrons() { return db .selectFrom("User") diff --git a/app/features/user-page/actions/u.$identifier.admin.server.ts b/app/features/user-page/actions/u.$identifier.admin.server.ts new file mode 100644 index 000000000..8a1d8352d --- /dev/null +++ b/app/features/user-page/actions/u.$identifier.admin.server.ts @@ -0,0 +1,57 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import * as AdminRepository from "~/features/admin/AdminRepository.server"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { adminTabActionSchema } from "~/features/user-page/user-page-schemas"; +import { requireRole } from "~/modules/permissions/guards.server"; +import { + badRequestIfFalsy, + notFoundIfFalsy, + parseRequestPayload, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const loggedInUser = await requireUser(request); + + requireRole(loggedInUser, "STAFF"); + + const data = await parseRequestPayload({ + request, + schema: adminTabActionSchema, + }); + + const user = notFoundIfFalsy( + await UserRepository.findLayoutDataByIdentifier(params.identifier!), + ); + + switch (data._action) { + case "ADD_MOD_NOTE": { + await AdminRepository.addModNote({ + authorId: loggedInUser.id, + userId: user.id, + text: data.value, + }); + break; + } + case "DELETE_MOD_NOTE": { + const note = badRequestIfFalsy( + await AdminRepository.findModeNoteById(data.noteId), + ); + + if (note.authorId !== loggedInUser.id) { + throw new Response(null, { + status: 401, + }); + } + + await AdminRepository.deleteModNote(data.noteId); + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/user-page/actions/u.$identifier.edit.server.ts b/app/features/user-page/actions/u.$identifier.edit.server.ts index 3990416e9..5f955dd2d 100644 --- a/app/features/user-page/actions/u.$identifier.edit.server.ts +++ b/app/features/user-page/actions/u.$identifier.edit.server.ts @@ -6,7 +6,7 @@ import * as UserRepository from "~/features/user-page/UserRepository.server"; import { safeParseRequestFormData } from "~/utils/remix.server"; import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql"; import { userPage } from "~/utils/urls"; -import { userEditActionSchema } from "../user-page-schemas.server"; +import { userEditActionSchema } from "../user-page-schemas"; export const action: ActionFunction = async ({ request }) => { const parsedInput = await safeParseRequestFormData({ diff --git a/app/features/user-page/actions/u.$identifier.results.highlights.server.ts b/app/features/user-page/actions/u.$identifier.results.highlights.server.ts index 999353dd1..f9f298961 100644 --- a/app/features/user-page/actions/u.$identifier.results.highlights.server.ts +++ b/app/features/user-page/actions/u.$identifier.results.highlights.server.ts @@ -8,7 +8,7 @@ import { import { normalizeFormFieldArray } from "~/utils/arrays"; import { parseRequestPayload } from "~/utils/remix.server"; import { userResultsPage } from "~/utils/urls"; -import { editHighlightsActionSchema } from "../user-page-schemas.server"; +import { editHighlightsActionSchema } from "../user-page-schemas"; export const action: ActionFunction = async ({ request }) => { const user = await requireUser(request); diff --git a/app/features/user-page/loaders/u.$identifier.admin.server.ts b/app/features/user-page/loaders/u.$identifier.admin.server.ts new file mode 100644 index 000000000..ec1548c53 --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.admin.server.ts @@ -0,0 +1,30 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { requireRole } from "~/modules/permissions/guards.server"; +import { logger } from "~/utils/logger"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { convertSnowflakeToDate } from "~/utils/users"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const loggedInUser = await requireUser(request); + + requireRole(loggedInUser, "STAFF"); + + const user = notFoundIfFalsy( + await UserRepository.findLayoutDataByIdentifier(params.identifier!), + ); + + logger.info( + `User ${loggedInUser.username} (#${loggedInUser.id}) is viewing admin tab for user ${user.username} (#${user.id})`, + ); + + const userData = notFoundIfFalsy( + await UserRepository.findModInfoById(user.id), + ); + + return { + ...userData, + discordAccountCreatedAt: convertSnowflakeToDate(user.discordId).getTime(), + }; +}; diff --git a/app/features/user-page/loaders/u.$identifier.art.server.ts b/app/features/user-page/loaders/u.$identifier.art.server.ts index 341e6cec1..2f65c0aa3 100644 --- a/app/features/user-page/loaders/u.$identifier.art.server.ts +++ b/app/features/user-page/loaders/u.$identifier.art.server.ts @@ -4,7 +4,7 @@ import { getUserId } from "~/features/auth/core/user.server"; import { countUnvalidatedArt } from "~/features/img-upload"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { notFoundIfFalsy } from "~/utils/remix.server"; -import { userParamsSchema } from "../user-page-schemas.server"; +import { userParamsSchema } from "../user-page-schemas"; export const loader = async ({ params, request }: LoaderFunctionArgs) => { const loggedInUser = await getUserId(request); diff --git a/app/features/user-page/loaders/u.$identifier.builds.server.ts b/app/features/user-page/loaders/u.$identifier.builds.server.ts index 60ad90322..453757017 100644 --- a/app/features/user-page/loaders/u.$identifier.builds.server.ts +++ b/app/features/user-page/loaders/u.$identifier.builds.server.ts @@ -7,7 +7,7 @@ import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfFalsy, privatelyCachedJson } from "~/utils/remix.server"; import { sortBuilds } from "../core/build-sorting.server"; -import { userParamsSchema } from "../user-page-schemas.server"; +import { userParamsSchema } from "../user-page-schemas"; export type UserBuildsPageData = SerializeFrom; diff --git a/app/features/user-page/loaders/u.$identifier.edit.server.ts b/app/features/user-page/loaders/u.$identifier.edit.server.ts index d1665095f..ea63b3628 100644 --- a/app/features/user-page/loaders/u.$identifier.edit.server.ts +++ b/app/features/user-page/loaders/u.$identifier.edit.server.ts @@ -3,7 +3,7 @@ import { requireUserId } from "~/features/auth/core/user.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { notFoundIfFalsy } from "~/utils/remix.server"; import { userPage } from "~/utils/urls"; -import { userParamsSchema } from "../user-page-schemas.server"; +import { userParamsSchema } from "../user-page-schemas"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUserId(request); diff --git a/app/features/user-page/loaders/u.$identifier.index.server.ts b/app/features/user-page/loaders/u.$identifier.index.server.ts index a53a3b0d1..95e547f84 100644 --- a/app/features/user-page/loaders/u.$identifier.index.server.ts +++ b/app/features/user-page/loaders/u.$identifier.index.server.ts @@ -1,21 +1,13 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -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 { notFoundIfFalsy } from "~/utils/remix.server"; -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const loggedInUser = await getUser(request); - +export const loader = async ({ params }: LoaderFunctionArgs) => { const user = notFoundIfFalsy( await UserRepository.findProfileByIdentifier(params.identifier!), ); return { user, - banned: - loggedInUser?.roles.includes("ADMIN") && userIsBanned(user.id) - ? await UserRepository.findBannedStatusByUserId(user.id)! - : undefined, }; }; diff --git a/app/features/user-page/loaders/u.$identifier.seasons.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.server.ts index 492d08837..427f8459a 100644 --- a/app/features/user-page/loaders/u.$identifier.seasons.server.ts +++ b/app/features/user-page/loaders/u.$identifier.seasons.server.ts @@ -16,7 +16,7 @@ import { notFoundIfFalsy } from "~/utils/remix.server"; import { seasonsSearchParamsSchema, userParamsSchema, -} from "../user-page-schemas.server"; +} from "../user-page-schemas"; export const loader = async ({ params, request }: LoaderFunctionArgs) => { const { identifier } = userParamsSchema.parse(params); diff --git a/app/features/user-page/routes/u.$identifier.admin.module.css b/app/features/user-page/routes/u.$identifier.admin.module.css new file mode 100644 index 000000000..ae1076f14 --- /dev/null +++ b/app/features/user-page/routes/u.$identifier.admin.module.css @@ -0,0 +1,11 @@ +.dl dt { + font-weight: bold; +} + +.dl dt:not(:first-child) { + margin-block-start: var(--s-4); +} + +.dl dd { + display: inline-block; +} diff --git a/app/features/user-page/routes/u.$identifier.admin.tsx b/app/features/user-page/routes/u.$identifier.admin.tsx new file mode 100644 index 000000000..41338f549 --- /dev/null +++ b/app/features/user-page/routes/u.$identifier.admin.tsx @@ -0,0 +1,229 @@ +import { useLoaderData } from "@remix-run/react"; +import type { z } from "zod/v4"; +import { Divider } from "~/components/Divider"; +import { Main } from "~/components/Main"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { SendouForm } from "~/components/form/SendouForm"; +import { TextAreaFormField } from "~/components/form/TextAreaFormField"; +import { PlusIcon } from "~/components/icons/Plus"; +import { USER } from "~/features/user-page/user-page-constants"; +import { addModNoteSchema } from "~/features/user-page/user-page-schemas"; +import { databaseTimestampToDate } from "~/utils/dates"; +import styles from "./u.$identifier.admin.module.css"; + +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { useUser } from "~/features/auth/core/user"; +import { action } from "../actions/u.$identifier.admin.server"; +import { loader } from "../loaders/u.$identifier.admin.server"; +export { loader, action }; + +export default function UserAdminPage() { + return ( +
+ +
+ + Mod notes + + +
+ +
+ + Ban log + + +
+
+ ); +} + +function AccountInfos() { + const data = useLoaderData(); + + return ( +
+
User account created at
+
+ {data.createdAt + ? databaseTimestampToDate(data.createdAt).toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "―"} +
+ +
Discord account created at
+
+ {new Date(data.discordAccountCreatedAt).toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +
+ +
Discord name
+
{data.discordUniqueName}
+ +
Artist role
+
{data.isArtist ? "Yes" : "No"}
+ +
Video adder role
+
{data.isVideoAdder ? "Yes" : "No"}
+ +
Tournament adder role
+
{data.isTournamentOrganizer ? "Yes" : "No"}
+ +
SQ leaderboard Plus Server admission skipped
+
+ {data.plusSkippedForSeasonNth + ? `For season ${data.plusSkippedForSeasonNth}` + : "No"} +
+
+ ); +} + +function ModNotes() { + const user = useUser(); + const data = useLoaderData(); + + if (!data.modNotes || data.modNotes.length === 0) { + return ( +
+

No mod notes

+ +
+ ); + } + + return ( +
+ {data.modNotes.map((note) => ( +
+

+ {databaseTimestampToDate(note.createdAt).toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+

By: {note.username}

+

Note: {note.text}

+ {note.discordId === user?.discordId ? ( + + + Delete + + + ) : null} +
+ ))} + +
+ ); +} + +type FormFields = z.infer; + +function NewModNoteDialog() { + return ( + } className="ml-auto mt-6"> + New note + + } + > + + + name="value" + label="Text" + maxLength={USER.MOD_NOTE_MAX_LENGTH} + bottomText="This note will be only visible to staff members." + /> + + + ); +} + +function BanLog() { + const data = useLoaderData(); + + if (!data.banLogs || data.banLogs.length === 0) { + return

No bans

; + } + + return ( +
+ {data.banLogs.map((ban) => ( +
+

+ {databaseTimestampToDate(ban.createdAt).toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+ {ban.banned === 0 ? ( +

Unbanned

+ ) : ( +

Banned

+ )} +

By: {ban.username}

+ {typeof ban.banned === "number" && ban.banned !== 0 ? ( +

+ Banned till:{" "} + {ban.banned !== 1 + ? databaseTimestampToDate(ban.banned).toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "No end date set"} +

+ ) : null} + {ban.banned !== 0 ? ( +

+ Reason:{" "} + {ban.bannedReason || ( + No reason set + )} +

+ ) : null} +
+ ))} +
+ ); +} diff --git a/app/features/user-page/routes/u.$identifier.edit.test.ts b/app/features/user-page/routes/u.$identifier.edit.test.ts index cca22759b..bd2fa2bc3 100644 --- a/app/features/user-page/routes/u.$identifier.edit.test.ts +++ b/app/features/user-page/routes/u.$identifier.edit.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test"; -import type { userEditActionSchema } from "../user-page-schemas.server"; +import type { userEditActionSchema } from "../user-page-schemas"; import { action as editUserProfileAction } from "./u.$identifier.edit"; const action = wrappedAction({ diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index 6bbdfa8c9..81f77eff4 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -13,7 +13,6 @@ import { TwitchIcon } from "~/components/icons/Twitch"; import { YouTubeIcon } from "~/components/icons/YouTube"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; import { modesShort } from "~/modules/in-game-lists/modes"; -import { databaseTimestampToDate } from "~/utils/dates"; import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { rawSensToString } from "~/utils/strings"; @@ -71,7 +70,6 @@ export default function UserInfoPage() { ) : null} - @@ -341,40 +339,3 @@ function TopPlacements() { ); } - -function BannedInfo() { - const data = useLoaderData(); - - const { banned, bannedReason } = data.banned ?? {}; - - if (!banned) return null; - - const ends = (() => { - if (!banned || banned === 1) return null; - - return databaseTimestampToDate(banned); - })(); - - return ( -
-

Account suspended

- {bannedReason ?
Reason: {bannedReason}
: null} - {ends ? ( -
- Ends:{" "} - {ends.toLocaleString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "numeric", - })} -
- ) : ( -
- Ends: no end time set -
- )} -
- ); -} diff --git a/app/features/user-page/routes/u.$identifier.tsx b/app/features/user-page/routes/u.$identifier.tsx index b8348c7a8..30b126ff2 100644 --- a/app/features/user-page/routes/u.$identifier.tsx +++ b/app/features/user-page/routes/u.$identifier.tsx @@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import { SubNav, SubNavLink } from "~/components/SubNav"; import { useUser } from "~/features/auth/core/user"; +import { useHasRole } from "~/modules/permissions/hooks"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { USER_SEARCH_PAGE, navIconUrl, - userArtPage, + userAdminPage, userBuildsPage, userEditProfilePage, userPage, @@ -61,6 +62,7 @@ export const handle: SendouRouteHandle = { export default function UserPageLayout() { const data = useLoaderData(); const user = useUser(); + const isStaff = useHasRole("STAFF"); const location = useLocation(); const { t } = useTranslation(["common", "user"]); @@ -102,10 +104,8 @@ export default function UserPageLayout() { {t("common:pages.vods")} ({data.user.vodsCount}) )} - {(data.user.artCount > 0 || isOwnPage) && ( - - {t("common:pages.art")} ({data.user.artCount}) - + {isStaff && ( + Admin )} diff --git a/app/features/user-page/user-page-constants.ts b/app/features/user-page/user-page-constants.ts index f23575c85..1c3f3961b 100644 --- a/app/features/user-page/user-page-constants.ts +++ b/app/features/user-page/user-page-constants.ts @@ -7,6 +7,7 @@ export const USER = { IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH: 5, WEAPON_POOL_MAX_SIZE: 5, COMMISSION_TEXT_MAX_LENGTH: 1000, + MOD_NOTE_MAX_LENGTH: 2000, }; export const MATCHES_PER_SEASONS_PAGE = 8; diff --git a/app/features/user-page/user-page-schemas.server.ts b/app/features/user-page/user-page-schemas.ts similarity index 90% rename from app/features/user-page/user-page-schemas.server.ts rename to app/features/user-page/user-page-schemas.ts index 92ac6563f..31f0a5245 100644 --- a/app/features/user-page/user-page-schemas.server.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { BADGE } from "~/features/badges/badges-constants"; import { isCustomUrl } from "~/utils/urls"; import { + _action, actualNumber, checkboxValueToDbBoolean, customCssVarObject, @@ -142,3 +143,18 @@ export const editHighlightsActionSchema = z.object({ z.union([z.array(z.string()), z.string()]), ), }); + +export const addModNoteSchema = z.object({ + _action: _action("ADD_MOD_NOTE"), + value: z.string().trim().min(1).max(USER.MOD_NOTE_MAX_LENGTH), +}); + +export const deleteModNoteSchema = z.object({ + _action: _action("DELETE_MOD_NOTE"), + noteId: id, +}); + +export const adminTabActionSchema = z.union([ + addModNoteSchema, + deleteModNoteSchema, +]); diff --git a/app/routes.ts b/app/routes.ts index 405d65c28..b1c158859 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -51,6 +51,7 @@ export default [ "results/highlights", "features/user-page/routes/u.$identifier.results.highlights.tsx", ), + route("admin", "features/user-page/routes/u.$identifier.admin.tsx"), ]), route("/badges", "features/badges/routes/badges.tsx", [ diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 9f0b42c87..8710275d6 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -174,6 +174,7 @@ export const newVodPage = (vodToEditId?: number) => `${VODS_PAGE}/new${vodToEditId ? `?vod=${vodToEditId}` : ""}`; export const userResultsEditHighlightsPage = (user: UserLinkArgs) => `${userResultsPage(user)}/highlights`; +export const userAdminPage = (user: UserLinkArgs) => `${userPage(user)}/admin`; export const artPage = (tag?: string) => `/art${tag ? `?tag=${tag}` : ""}`; export const userArtPage = ( user: UserLinkArgs, diff --git a/app/utils/users.ts b/app/utils/users.ts index 72cbc2399..6cf14d931 100644 --- a/app/utils/users.ts +++ b/app/utils/users.ts @@ -36,7 +36,7 @@ export function queryToUserIdentifier( const DISCORD_EPOCH = 1420070400000; // Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided -function convertSnowflakeToDate(snowflake: string) { +export function convertSnowflakeToDate(snowflake: string) { // Convert snowflake to BigInt to extract timestamp bits // https://discord.com/developers/docs/reference#snowflakes const milliseconds = BigInt(snowflake) >> 22n; diff --git a/migrations/089-ban-log.js b/migrations/089-ban-log.js new file mode 100644 index 000000000..8d3dd428b --- /dev/null +++ b/migrations/089-ban-log.js @@ -0,0 +1,37 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `create table "BanLog" ( + "id" integer primary key, + "userId" integer not null, + "banned" integer, + "bannedReason" text, + "bannedByUserId" integer not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("userId") references "User"("id") on delete restrict, + foreign key ("bannedByUserId") references "User"("id") on delete restrict + )`, + ).run(); + + db.prepare( + /*sql*/ `create index ban_log_user_id on "BanLog"("userId")`, + ).run(); + + db.prepare( + /* sql */ `create table "ModNote" ( + "id" integer primary key, + "userId" integer not null, + "authorId" integer not null, + "text" text not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + "isDeleted" integer not null default 0, + foreign key ("userId") references "User"("id") on delete restrict, + foreign key ("authorId") references "User"("id") on delete restrict + )`, + ).run(); + + db.prepare( + /*sql*/ `create index mod_note_user_id on "ModNote"("userId")`, + ).run(); + })(); +}