mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
User admin tab Closes #2388
This commit is contained in:
parent
96fe5f0c78
commit
df3772403e
|
|
@ -888,6 +888,24 @@ export interface UserFriendCode {
|
|||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
||||
export interface BanLog {
|
||||
id: GeneratedAlways<number>;
|
||||
userId: number;
|
||||
banned: number | null;
|
||||
bannedReason: string | null;
|
||||
bannedByUserId: number;
|
||||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
||||
export interface ModNote {
|
||||
id: GeneratedAlways<number>;
|
||||
userId: number;
|
||||
authorId: number;
|
||||
text: string;
|
||||
createdAt: GeneratedAlways<number>;
|
||||
isDeleted: Generated<DBBoolean>;
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
eventId: number | null;
|
||||
id: GeneratedAlways<number>;
|
||||
|
|
@ -1039,6 +1057,8 @@ export interface DB {
|
|||
BadgeManager: BadgeManager;
|
||||
BadgeOwner: BadgeOwner;
|
||||
TournamentBadgeOwner: TournamentBadgeOwner;
|
||||
BanLog: BanLog;
|
||||
ModNote: ModNote;
|
||||
Build: Build;
|
||||
BuildAbility: BuildAbility;
|
||||
BuildWeapon: BuildWeapon;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
57
app/features/user-page/actions/u.$identifier.admin.server.ts
Normal file
57
app/features/user-page/actions/u.$identifier.admin.server.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
30
app/features/user-page/loaders/u.$identifier.admin.server.ts
Normal file
30
app/features/user-page/loaders/u.$identifier.admin.server.ts
Normal file
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<typeof loader>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
11
app/features/user-page/routes/u.$identifier.admin.module.css
Normal file
11
app/features/user-page/routes/u.$identifier.admin.module.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
229
app/features/user-page/routes/u.$identifier.admin.tsx
Normal file
229
app/features/user-page/routes/u.$identifier.admin.tsx
Normal file
|
|
@ -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 (
|
||||
<Main className="stack xl">
|
||||
<AccountInfos />
|
||||
<div className="stack sm">
|
||||
<Divider smallText className="font-bold">
|
||||
Mod notes
|
||||
</Divider>
|
||||
<ModNotes />
|
||||
</div>
|
||||
|
||||
<div className="stack sm">
|
||||
<Divider smallText className="font-bold">
|
||||
Ban log
|
||||
</Divider>
|
||||
<BanLog />
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountInfos() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<dl className={styles.dl}>
|
||||
<dt>User account created at</dt>
|
||||
<dd>
|
||||
{data.createdAt
|
||||
? databaseTimestampToDate(data.createdAt).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "―"}
|
||||
</dd>
|
||||
|
||||
<dt>Discord account created at</dt>
|
||||
<dd>
|
||||
{new Date(data.discordAccountCreatedAt).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</dd>
|
||||
|
||||
<dt>Discord name</dt>
|
||||
<dd>{data.discordUniqueName}</dd>
|
||||
|
||||
<dt>Artist role</dt>
|
||||
<dd>{data.isArtist ? "Yes" : "No"}</dd>
|
||||
|
||||
<dt>Video adder role</dt>
|
||||
<dd>{data.isVideoAdder ? "Yes" : "No"}</dd>
|
||||
|
||||
<dt>Tournament adder role</dt>
|
||||
<dd>{data.isTournamentOrganizer ? "Yes" : "No"}</dd>
|
||||
|
||||
<dt>SQ leaderboard Plus Server admission skipped</dt>
|
||||
<dd>
|
||||
{data.plusSkippedForSeasonNth
|
||||
? `For season ${data.plusSkippedForSeasonNth}`
|
||||
: "No"}
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function ModNotes() {
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (!data.modNotes || data.modNotes.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center text-lighter italic">No mod notes</p>
|
||||
<NewModNoteDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{data.modNotes.map((note) => (
|
||||
<div key={note.noteId}>
|
||||
<p className="font-bold">
|
||||
{databaseTimestampToDate(note.createdAt).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
<p className="ml-2">By: {note.username}</p>
|
||||
<p className="ml-2 whitespace-pre-wrap">Note: {note.text}</p>
|
||||
{note.discordId === user?.discordId ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading="Delete mod note?"
|
||||
fields={[
|
||||
["_action", "DELETE_MOD_NOTE"],
|
||||
["noteId", note.noteId],
|
||||
]}
|
||||
>
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
size="small"
|
||||
className="ml-2"
|
||||
>
|
||||
Delete
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<NewModNoteDialog key={data.modNotes.length} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FormFields = z.infer<typeof addModNoteSchema>;
|
||||
|
||||
function NewModNoteDialog() {
|
||||
return (
|
||||
<SendouDialog
|
||||
heading="Adding a new mod note"
|
||||
showCloseButton
|
||||
trigger={
|
||||
<SendouButton icon={<PlusIcon />} className="ml-auto mt-6">
|
||||
New note
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<SendouForm
|
||||
schema={addModNoteSchema}
|
||||
defaultValues={{
|
||||
value: "",
|
||||
_action: "ADD_MOD_NOTE",
|
||||
}}
|
||||
>
|
||||
<TextAreaFormField<FormFields>
|
||||
name="value"
|
||||
label="Text"
|
||||
maxLength={USER.MOD_NOTE_MAX_LENGTH}
|
||||
bottomText="This note will be only visible to staff members."
|
||||
/>
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BanLog() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (!data.banLogs || data.banLogs.length === 0) {
|
||||
return <p className="text-center text-lighter italic">No bans</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{data.banLogs.map((ban) => (
|
||||
<div key={ban.createdAt}>
|
||||
<p className="font-bold">
|
||||
{databaseTimestampToDate(ban.createdAt).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
{ban.banned === 0 ? (
|
||||
<p className="text-success ml-2">Unbanned</p>
|
||||
) : (
|
||||
<p className="text-warning ml-2">Banned</p>
|
||||
)}
|
||||
<p className="ml-2">By: {ban.username}</p>
|
||||
{typeof ban.banned === "number" && ban.banned !== 0 ? (
|
||||
<p className="ml-2">
|
||||
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"}
|
||||
</p>
|
||||
) : null}
|
||||
{ban.banned !== 0 ? (
|
||||
<p className="ml-2">
|
||||
Reason:{" "}
|
||||
{ban.bannedReason || (
|
||||
<span className="italic">No reason set</span>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof userEditActionSchema>({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</div>
|
||||
</div>
|
||||
<BannedInfo />
|
||||
<ExtraInfos />
|
||||
<WeaponPool />
|
||||
<TopPlacements />
|
||||
|
|
@ -341,40 +339,3 @@ function TopPlacements() {
|
|||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function BannedInfo() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const { banned, bannedReason } = data.banned ?? {};
|
||||
|
||||
if (!banned) return null;
|
||||
|
||||
const ends = (() => {
|
||||
if (!banned || banned === 1) return null;
|
||||
|
||||
return databaseTimestampToDate(banned);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h2 className="text-warning">Account suspended</h2>
|
||||
{bannedReason ? <div>Reason: {bannedReason}</div> : null}
|
||||
{ends ? (
|
||||
<div suppressHydrationWarning>
|
||||
Ends:{" "}
|
||||
{ends.toLocaleString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
Ends: <i>no end time set</i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof loader>();
|
||||
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})
|
||||
</SubNavLink>
|
||||
)}
|
||||
{(data.user.artCount > 0 || isOwnPage) && (
|
||||
<SubNavLink to={userArtPage(data.user)} end={false}>
|
||||
{t("common:pages.art")} ({data.user.artCount})
|
||||
</SubNavLink>
|
||||
{isStaff && (
|
||||
<SubNavLink to={userAdminPage(data.user)}>Admin</SubNavLink>
|
||||
)}
|
||||
</SubNav>
|
||||
<Outlet />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
37
migrations/089-ban-log.js
Normal file
37
migrations/089-ban-log.js
Normal file
|
|
@ -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();
|
||||
})();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user