From ca9e5112506bf9fb405ab4bf60a6d4cf508edc24 Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 19 Jun 2025 15:33:47 -0600 Subject: [PATCH] implement moderator UI --- package-lock.json | 1 + package.json | 1 + .../client/src/Moderator/ModCanvasOverlay.tsx | 73 +++++ packages/client/src/Moderator/ModSidebar.tsx | 222 +++++++++++++++ packages/client/src/Moderator/Moderator.tsx | 154 ++++++++++ .../client/src/Moderator/UserModSidebar.tsx | 267 ++++++++++++++++++ packages/client/src/components/App.tsx | 16 +- .../client/src/components/CanvasWrapper.tsx | 4 + .../src/components/Header/HeaderRight.tsx | 22 +- .../client/src/components/KeybindModal.tsx | 30 +- .../src/components/Moderation/ModModal.tsx | 136 --------- .../PixelSidebar/PixelWhoisSidebar.tsx | 8 +- .../src/components/Profile/ProfileModal.tsx | 36 +-- .../src/components/Profile/UserCard.tsx | 82 +++++- .../client/src/components/SidebarBase.tsx | 28 +- packages/client/src/contexts/AppContext.tsx | 30 +- packages/client/src/hooks/useHasRole.ts | 13 + packages/client/src/lib/keybinds.ts | 8 +- packages/client/src/lib/utils.ts | 24 +- packages/lib/src/index.ts | 29 ++ packages/server/src/api/client/auth.ts | 15 + packages/server/src/api/mod/canvas.ts | 43 ++- packages/server/src/api/mod/index.ts | 1 + packages/server/src/api/openapi.yml | 139 +++++++++ .../src/controllers/CanvasController.ts | 8 +- 25 files changed, 1156 insertions(+), 234 deletions(-) create mode 100644 packages/client/src/Moderator/ModCanvasOverlay.tsx create mode 100644 packages/client/src/Moderator/ModSidebar.tsx create mode 100644 packages/client/src/Moderator/Moderator.tsx create mode 100644 packages/client/src/Moderator/UserModSidebar.tsx delete mode 100644 packages/client/src/components/Moderation/ModModal.tsx create mode 100644 packages/client/src/hooks/useHasRole.ts diff --git a/package-lock.json b/package-lock.json index 18785e5..b004d44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@internationalized/date": "^3.6.0", "@quixo3/prisma-session-store": "^3.1.13", "@sentry/react": "^8.48.0", "next-themes": "^0.4.4", diff --git a/package.json b/package.json index 5660423..dce9c59 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@internationalized/date": "^3.6.0", "@quixo3/prisma-session-store": "^3.1.13", "@sentry/react": "^8.48.0", "next-themes": "^0.4.4", diff --git a/packages/client/src/Moderator/ModCanvasOverlay.tsx b/packages/client/src/Moderator/ModCanvasOverlay.tsx new file mode 100644 index 0000000..8d3ec45 --- /dev/null +++ b/packages/client/src/Moderator/ModCanvasOverlay.tsx @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from "react"; +import { KeybindManager } from "../lib/keybinds"; +import { Canvas } from "../lib/canvas"; +import { useModerator } from "./Moderator"; + +export const ModCanvasOverlay = () => { + const [mode, setMode] = useState<"SELECT_POINT_1" | "SELECT_POINT_2">( + "SELECT_POINT_1" + ); + const { state, dispatch } = useModerator(); + + const handleClick = useCallback( + (e: { clientX: number; clientY: number }) => { + const canvasPos = Canvas.instance?.screenToPos(e.clientX, e.clientY); + if (canvasPos) { + if (mode === "SELECT_POINT_2") { + dispatch({ action: "OPEN_MENU" }); + } + dispatch({ + action: mode, + x: canvasPos[0], + y: canvasPos[1], + }); + setMode((m) => + m === "SELECT_POINT_1" ? "SELECT_POINT_2" : "SELECT_POINT_1" + ); + } + }, + [dispatch, mode] + ); + + const handleClear = useCallback(() => { + dispatch({ action: "END_SELECT_AREA" }); + }, [dispatch]); + + useEffect(() => { + KeybindManager.on("MOD_SELECT", handleClick); + KeybindManager.on("DESELECT_COLOR", handleClear); + + return () => { + KeybindManager.off("MOD_SELECT", handleClick); + KeybindManager.off("DESELECT_COLOR", handleClear); + }; + }, [handleClear, handleClick]); + + if (!state.selection?.selecting) return <>; + + const point1 = [ + state.selection?.point1?.[0] || 0, + state.selection?.point1?.[1] || 0, + ]; + const point2 = [ + state.selection?.point2?.[0] || 0, + state.selection?.point2?.[1] || 0, + ]; + + const width = point2[0] + 1 - point1[0]; + const height = point2[1] + 1 - point1[1]; + + return ( +
+ ); +}; diff --git a/packages/client/src/Moderator/ModSidebar.tsx b/packages/client/src/Moderator/ModSidebar.tsx new file mode 100644 index 0000000..f19e993 --- /dev/null +++ b/packages/client/src/Moderator/ModSidebar.tsx @@ -0,0 +1,222 @@ +import { faHammer } from "@fortawesome/free-solid-svg-icons"; +import { SidebarBase } from "../components/SidebarBase"; +import { useModerator } from "./Moderator"; +import { Alert, Button, Chip } from "@nextui-org/react"; +import { KeybindManager } from "../lib/keybinds"; +import { Keybind } from "../components/KeybindModal"; +import { useCallback, useState } from "react"; +import { handleError, oapi } from "../lib/utils"; +import { EError, RestAPI } from "@sc07-canvas/lib"; +import { UserCard } from "../components/Profile/UserCard"; +import { toast } from "react-toastify"; + +export const ModSidebar = () => { + const { state, dispatch } = useModerator(); + + return ( + + dispatch({ action: show ? "OPEN_MENU" : "CLOSE_MENU" }) + } + icon={faHammer} + title="Moderator" + side="Left" + noBackdrop + > +
+ +
+
+ ); +}; + +type Pixel = RestAPI.components["schemas"]["ModPixelQuery"]["pixels"][number]; + +interface IPixelSummary { + total: number; + users: { + user: RestAPI.components["schemas"]["SparseUser"]; + pixels: number; + topPixels: number; + hasMostTopPixels: boolean; + }[]; +} + +const summarizePixels = (pixels: Pixel[]): IPixelSummary => { + const users: Map = + new Map(); + for (const pixel of pixels) { + users.set(pixel.userId, pixel.user); + } + + const userPixels = pixels.reduce( + (p, c) => { + if (!c.isTop) return p; + + if (!p.hasOwnProperty(c.userId)) { + p[c.userId] = 0; + } + p[c.userId]++; + return p; + }, + {} as { [k: string]: number } + ); + const topPixelCount = Object.values(userPixels).sort((a, b) => b - a)[0]; + + return { + total: pixels.length, + users: [...users.values()] + .map((u) => ({ + user: u, + pixels: pixels.filter((p) => p.userId === u.sub).length, + topPixels: pixels.filter((p) => p.userId === u.sub && p.isTop).length, + hasMostTopPixels: + pixels.filter((p) => p.userId === u.sub && p.isTop).length === + topPixelCount, + })) + .sort((a, b) => b.pixels - a.pixels), + }; +}; + +const SelectedArea = () => { + const { state } = useModerator(); + const [isLoading, setIsLoading] = useState(false); + const [pixels, setPixels] = useState([]); + + const doFetchQuery = useCallback( + (from: `${number},${number}`, to: `${number},${number}`) => { + setIsLoading(true); + oapi + .GET("/mod/canvas/query", { + params: { + query: { + from, + to, + }, + }, + }) + .then(({ data }) => { + if (data) setPixels(data.pixels); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [] + ); + + if (!state.selection?.point1 || !state.selection?.point2) { + return ( + + Use to + select 2 regions on the canvas + + } + /> + ); + } + + const from = state.selection.point1; + const to = state.selection.point2; + const area = [to[0] + 1 - from[0], to[1] + 1 - from[1]]; + const summary = summarizePixels(pixels); + + return ( + <> +
+ Canvas Selection{" "} + + ({from.join(", ")}) -> ({to.join(", ")}) + + + {area[0]} x {area[1]} + +
+ + + + +
+ {summary.users.map((user) => ( +
+ + total pixels: {user.pixels} + + top pixels: {user.topPixels} + +
+ ))} +
+ + ); +}; + +const UndoAreaButton = ({ + from, + to, +}: { + from: [x: number, y: number]; + to: [x: number, y: number]; +}) => { + const [loading, setLoading] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + + const doUndo = useCallback(() => { + setLoading(true); + oapi + .POST("/mod/canvas/undo", { + body: { + start: { x: from[0], y: from[1] }, + end: { x: to[0], y: to[1] }, + }, + }) + .then(({ error, response }) => { + if (response.ok) { + toast.success("Undone area " + JSON.stringify([from, to])); + } else { + handleError(new EError(error, response.status)); + } + }) + .finally(() => { + setLoading(false); + setIsConfirming(false); + }); + }, [from, to]); + + return ( + <> + + {isConfirming && ( + <> + + + + )} + + ); +}; diff --git a/packages/client/src/Moderator/Moderator.tsx b/packages/client/src/Moderator/Moderator.tsx new file mode 100644 index 0000000..31f5275 --- /dev/null +++ b/packages/client/src/Moderator/Moderator.tsx @@ -0,0 +1,154 @@ +import React, { createContext, useContext, useEffect, useReducer } from "react"; +import { UserModSidebar } from "./UserModSidebar"; +import { KeybindManager } from "../lib/keybinds"; +import { ModSidebar } from "./ModSidebar"; +import { useHasRole } from "../hooks/useHasRole"; + +const context = createContext<{ + state: IModeratorContext; + dispatch: React.ActionDispatch<[ModerationActions]>; +}>({ + state: { + showMenu: false, + }, + dispatch: () => {}, +}); + +interface IModeratorContext { + inspectUser?: string; + showMenu: boolean; + selection?: { + selecting: boolean; + point1?: [x: number, y: number]; + point2?: [x: number, y: number]; + }; +} + +type ModerationActions = + | { action: "INSPECT_USER"; user?: string } + | { action: "OPEN_MENU" } + | { action: "CLOSE_MENU" } + | { action: "START_SELECT_AREA" } + | { action: "END_SELECT_AREA" } + | { action: "SELECT_POINT_1"; x: number; y: number } + | { action: "SELECT_POINT_2"; x: number; y: number }; + +export const useModerator = () => useContext(context); + +export const ModeratorContext = ({ children }: React.PropsWithChildren) => { + const isMod = useHasRole("MOD"); + + const [state, dispatch] = useReducer( + (state, data) => { + switch (data.action) { + case "INSPECT_USER": + return { + ...state, + inspectUser: data.user, + }; + case "OPEN_MENU": + return { + ...state, + showMenu: true, + }; + case "CLOSE_MENU": + return { + ...state, + showMenu: false, + }; + case "START_SELECT_AREA": + return { + ...state, + selection: { + selecting: true, + // intentional overwrite of existing properties + }, + }; + case "END_SELECT_AREA": + return { + ...state, + selection: { + selecting: false, + // intentional overwrite of existing properties + }, + }; + case "SELECT_POINT_1": + return { + ...state, + selection: { + selecting: true, + point1: [data.x, data.y], + point2: undefined, + }, + }; + case "SELECT_POINT_2": + return { + ...state, + selection: { + ...state.selection, + selecting: true, + point2: [data.x, data.y], + }, + }; + } + + throw new Error( + "Unknown ModeratorContext action: " + JSON.stringify(data) + ); + }, + { + inspectUser: undefined, + showMenu: false, + } + ); + + useEffect(() => { + const handleKeybind = () => { + if (!isMod) { + console.warn("TOGGLE_MOD_MENU canceled, user is not a moderator"); + return; + } + dispatch({ action: "OPEN_MENU" }); + }; + + KeybindManager.on("TOGGLE_MOD_MENU", handleKeybind); + + return () => { + KeybindManager.off("TOGGLE_MOD_MENU", handleKeybind); + }; + }, [isMod]); + + return ( + {children} + ); +}; + +export const Moderator = () => { + return ( + <> + + + + ); +}; + +interface AdminUIUrls { + ip_lookup: [address: string]; + user: [sub: string]; +} + +export class ModeratorUtils { + static url( + which: T, + ...args: AdminUIUrls[T] + ): string { + switch (which) { + case "ip_lookup": + return `/admin/accounts?filter[ip]=${encodeURIComponent(args[0])}`; + case "user": + return `/admin/accounts/${encodeURIComponent(args[0])}`; + } + + throw new Error("Unknown URL: " + which); + } +} diff --git a/packages/client/src/Moderator/UserModSidebar.tsx b/packages/client/src/Moderator/UserModSidebar.tsx new file mode 100644 index 0000000..6d2035c --- /dev/null +++ b/packages/client/src/Moderator/UserModSidebar.tsx @@ -0,0 +1,267 @@ +import { faHammer } from "@fortawesome/free-solid-svg-icons"; +import { SidebarBase } from "../components/SidebarBase"; +import { ModeratorUtils, useModerator } from "./Moderator"; +import { useCallback, useEffect, useState } from "react"; +import { handleError, oapi, useQuery } from "../lib/utils"; +import { UserCard } from "../components/Profile/UserCard"; +import { + Accordion, + AccordionItem, + Button, + Chip, + DateInput, + Input, + Link, +} from "@nextui-org/react"; +import { CalendarDateTime, parseDateTime } from "@internationalized/date"; +import { toast } from "react-toastify"; +import { EError } from "@sc07-canvas/lib"; + +export const UserModSidebar = () => { + const { state, dispatch } = useModerator(); + + return ( + { + if (!val) dispatch({ action: "INSPECT_USER" }); + }} + icon={faHammer} + title="Moderator: User Inspect" + side="Left" + noBackdrop + > +
+ {state.inspectUser ? : <>} +
+
+ ); +}; + +const Inner = ({ sub }: { sub: string }) => { + const userReq = useQuery("/user/{sub}", { + params: { + path: { + sub, + }, + }, + }); + const ipReq = useQuery("/mod/user/{sub}/ips", { + params: { + path: { + sub, + }, + }, + }); + + return ( + <> + +
+ +
+ + +
    + {ipReq.data?.ips.map((ip) => )} +
+
+ + + + + + +
+ + ); +}; + +const Notice = ({ sub }: { sub: string }) => { + const [loading, setLoading] = useState(false); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + + const doSend = useCallback(() => { + setLoading(true); + oapi + .POST("/mod/user/{sub}/notice", { + params: { + path: { + sub, + }, + }, + body: { + title, + body: body || undefined, + }, + }) + .then(({ error, response }) => { + if (response.ok) { + toast.success("Sent notice successfully"); + setTitle(""); + setBody(""); + } else { + handleError(new EError(error, response.status)); + } + }) + .finally(() => { + setLoading(false); + }); + }, [body, sub, title]); + + return ( + <> + + + + + ); +}; + +const Ban = ({ sub }: { sub: string }) => { + const [lock, setLock] = useState(false); + const [loading, setLoading] = useState(false); + const [expiresAt, setExpiresAt] = useState(); + const [privateNote, setPrivateNote] = useState(""); + const [publicNote, setPublicNote] = useState(""); + + const doPrepare = useCallback(() => { + setLock(true); + }, []); + + const doBan = useCallback(() => { + if (!expiresAt) return; + + setLoading(true); + oapi + .PUT("/mod/user/{sub}/ban", { + params: { + path: { + sub, + }, + }, + body: { + expiresAt: expiresAt!.toString(), + privateNote: privateNote || undefined, + publicNote: publicNote || undefined, + }, + }) + .then(({ data, error, response }) => { + if (data) { + toast.success("Banned user successfully"); + } + if (error) { + handleError(new EError(error, response.status)); + } + }) + .finally(() => { + setLoading(false); + }); + }, [expiresAt, privateNote, publicNote, sub]); + + return ( + <> + + + Note visible to user + } + value={publicNote} + onValueChange={setPublicNote} + /> +
+ + {lock && ( + <> + + + + )} +
+ + ); +}; + +const IPLine = ({ + ip, +}: { + ip: { ip: string; lastUsedAt: string; createdAt: string }; +}) => { + const [overview, setOverview] = useState<{ users: number }>({ users: 0 }); + + useEffect(() => { + oapi + .GET("/mod/ip", { + params: { + query: { + address: ip.ip, + }, + }, + }) + .then(({ data }) => { + if (data) { + setOverview(data); + } + }); + }, [ip]); + + return ( +
  • + {ip.ip} + {overview.users} Users + + Lookup + +
  • + ); +}; diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx index e3e188e..0c78abf 100644 --- a/packages/client/src/components/App.tsx +++ b/packages/client/src/components/App.tsx @@ -17,9 +17,9 @@ import { KeybindModal } from "./KeybindModal"; import { ProfileModal } from "./Profile/ProfileModal"; import { WelcomeModal } from "./Welcome/WelcomeModal"; import { InfoSidebar } from "./Info/InfoSidebar"; -import { ModModal } from "./Moderation/ModModal"; import { DynamicModals } from "./DynamicModals"; import { ToastWrapper } from "./ToastWrapper"; +import { Moderator, ModeratorContext } from "../Moderator/Moderator"; // const Chat = lazy(() => import("./Chat/Chat")); @@ -151,7 +151,7 @@ const AppInner = () => { - + @@ -168,11 +168,13 @@ const App = () => { }} > - - - - - + + + + + + + ); diff --git a/packages/client/src/components/CanvasWrapper.tsx b/packages/client/src/components/CanvasWrapper.tsx index 3a07623..a310270 100644 --- a/packages/client/src/components/CanvasWrapper.tsx +++ b/packages/client/src/components/CanvasWrapper.tsx @@ -15,8 +15,11 @@ import { HeatmapOverlay } from "./Overlay/HeatmapOverlay"; import { useTemplateContext } from "../contexts/TemplateContext"; import { PixelPulses } from "./Overlay/PixelPulses"; import { CanvasUtils } from "../lib/canvas.utils"; +import { ModCanvasOverlay } from "../Moderator/ModCanvasOverlay"; +import { useHasRole } from "../hooks/useHasRole"; export const CanvasWrapper = () => { + const hasMod = useHasRole("MOD"); const { config } = useAppContext(); const getInitialPosition = useCallback< @@ -56,6 +59,7 @@ export const CanvasWrapper = () => { return (
    + {hasMod && } diff --git a/packages/client/src/components/Header/HeaderRight.tsx b/packages/client/src/components/Header/HeaderRight.tsx index 30f681a..b3d2707 100644 --- a/packages/client/src/components/Header/HeaderRight.tsx +++ b/packages/client/src/components/Header/HeaderRight.tsx @@ -3,8 +3,10 @@ import { useAppContext } from "../../contexts/AppContext"; import { User } from "./User"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ThemeSwitcher } from "./ThemeSwitcher"; -import { faGear, faHammer } from "@fortawesome/free-solid-svg-icons"; +import { faCog, faGear, faHammer } from "@fortawesome/free-solid-svg-icons"; import React, { lazy } from "react"; +import { useHasRole } from "../../hooks/useHasRole"; +import { useModerator } from "../../Moderator/Moderator"; const OpenChatButton = lazy(() => import("../Chat/OpenChatButton")); @@ -15,7 +17,10 @@ const DynamicChat = () => { }; export const HeaderRight = () => { - const { setSettingsSidebar, hasAdmin } = useAppContext(); + const { setSettingsSidebar } = useAppContext(); + const { dispatch } = useModerator(); + const hasAdmin = useHasRole("ADMIN"); + const hasMod = useHasRole("MOD"); return (
    @@ -26,9 +31,18 @@ export const HeaderRight = () => {

    Settings

    - {hasAdmin && ( - + )} + {(hasAdmin || hasMod) && ( + )} diff --git a/packages/client/src/components/KeybindModal.tsx b/packages/client/src/components/KeybindModal.tsx index 6864326..a198d0e 100644 --- a/packages/client/src/components/KeybindModal.tsx +++ b/packages/client/src/components/KeybindModal.tsx @@ -9,7 +9,22 @@ import { ModalHeader, } from "@nextui-org/react"; import { useAppContext } from "../contexts/AppContext"; -import { KeybindManager } from "../lib/keybinds"; +import { IKeybind, KeybindManager } from "../lib/keybinds"; + +export const Keybind = ({ kb }: { kb: IKeybind }) => ( + a)} + > + {kb.key} + +); export const KeybindModal = () => { const { showKeybinds, setShowKeybinds } = useAppContext(); @@ -30,18 +45,7 @@ export const KeybindModal = () => {
    {name} {kbs.map((kb) => ( - a)} - > - {kb.key} - + ))}
    ) diff --git a/packages/client/src/components/Moderation/ModModal.tsx b/packages/client/src/components/Moderation/ModModal.tsx deleted file mode 100644 index dc696fb..0000000 --- a/packages/client/src/components/Moderation/ModModal.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { - Button, - Modal, - ModalBody, - ModalContent, - ModalHeader, - Switch, -} from "@nextui-org/react"; -import { useAppContext } from "../../contexts/AppContext"; -import { useCallback, useEffect, useState } from "react"; -import { KeybindManager } from "../../lib/keybinds"; -import { Canvas } from "../../lib/canvas"; -import { toast } from "react-toastify"; -import { api, handleError } from "../../lib/utils"; - -export const ModModal = () => { - const { showModModal, setShowModModal, hasAdmin } = useAppContext(); - const [bypassCooldown, setBypassCooldown_] = useState(false); - const [selectedCoords, setSelectedCoords] = useState<{ - start: [x: number, y: number]; - end: [x: number, y: number]; - }>(); - const [loading, setLoading] = useState(false); - - useEffect(() => { - setBypassCooldown_(Canvas.instance?.getCooldownBypass() || false); - - const handleKeybind = () => { - if (!hasAdmin) { - console.warn("Unable to open mod menu; hasAdmin is not set"); - return; - } - - setShowModModal((m) => !m); - }; - - KeybindManager.on("TOGGLE_MOD_MENU", handleKeybind); - - return () => { - KeybindManager.off("TOGGLE_MOD_MENU", handleKeybind); - }; - }, [hasAdmin]); - - useEffect(() => { - const previousClicks = Canvas.instance?.previousCanvasClicks; - - if (previousClicks && previousClicks.length === 2) { - let start: [number, number] = [previousClicks[0].x, previousClicks[0].y]; - let end: [number, number] = [previousClicks[1].x, previousClicks[1].y]; - - if (start[0] < end[0] && start[1] < end[1]) { - setSelectedCoords({ - start, - end, - }); - } else { - setSelectedCoords(undefined); - } - } else { - setSelectedCoords(undefined); - } - }, [showModModal]); - - const setBypassCooldown = useCallback( - (value: boolean) => { - setBypassCooldown_(value); - Canvas.instance?.setCooldownBypass(value); - }, - [setBypassCooldown_] - ); - - const doUndoArea = useCallback(() => { - if (!selectedCoords) return; - if ( - !window.confirm( - `Are you sure you want to undo (${selectedCoords.start.join(",")}) -> (${selectedCoords.end.join(",")})\n\nThis will affect ~${(selectedCoords.end[0] - selectedCoords.start[0]) * (selectedCoords.end[1] - selectedCoords.start[1])} pixels!` - ) - ) { - return; - } - - setLoading(true); - api("/api/admin/canvas/undo", "PUT", { - start: { x: selectedCoords.start[0], y: selectedCoords.start[1] }, - end: { x: selectedCoords.end[0], y: selectedCoords.end[1] }, - }) - .then(({ status, data }) => { - if (status === 200) { - if (data.success) { - toast.success( - `Successfully undid area (${selectedCoords.start.join(",")}) -> (${selectedCoords.end.join(",")})` - ); - } else { - handleError({ status, data }); - } - } else { - handleError({ status, data }); - } - }) - .finally(() => { - setLoading(false); - }); - }, [selectedCoords]); - - return ( - - - {(_onClose) => ( - <> - Mod Menu - - - Bypass placement cooldown - - {selectedCoords && ( - - )} - {!selectedCoords && ( - <> - right click two positions to get more options (first click - needs to be the top left most position) - - )} - - - )} - - - ); -}; diff --git a/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx index a2d115d..02d9876 100644 --- a/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx +++ b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx @@ -117,7 +117,13 @@ export const PixelWhoisSidebar = () => { />
    -
    {whois?.user && }
    +
    + {whois?.user && ( + <> + + + )} +
    diff --git a/packages/client/src/components/Profile/ProfileModal.tsx b/packages/client/src/components/Profile/ProfileModal.tsx index 8e4b84c..4899865 100644 --- a/packages/client/src/components/Profile/ProfileModal.tsx +++ b/packages/client/src/components/Profile/ProfileModal.tsx @@ -7,9 +7,10 @@ import { ModalHeader, } from "@nextui-org/react"; import { useAppContext } from "../../contexts/AppContext"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { IUser, UserCard } from "./UserCard"; import { handleError, oapi } from "../../lib/utils"; +import { EError } from "@sc07-canvas/lib"; export const ProfileModal = () => { const { profile, setProfile } = useAppContext(); @@ -29,31 +30,13 @@ export const ProfileModal = () => { }, }, }) - .then(({ data, response }) => { + .then(({ data, error, response }) => { if (data) { setUser(data.user); - } else { - handleError({ status: response.status, data: response.body }); } - }); - }, [profile]); - - const test_getIPs = useCallback(() => { - if (!profile) { - alert("profile not found? wat"); - return; - } - - oapi - .GET("/mod/user/{sub}/ips", { - params: { - path: { - sub: profile, - }, - }, - }) - .then(({ data }) => { - console.log("get ips", data); + if (error) { + handleError(new EError(error, response.status)); + } }); }, [profile]); @@ -64,8 +47,11 @@ export const ProfileModal = () => { <> Profile - {user ? : <>Loading...} - + {user ? ( + + ) : ( + <>Loading... + )} diff --git a/packages/client/src/components/Profile/UserCard.tsx b/packages/client/src/components/Profile/UserCard.tsx index f2831b9..da10cde 100644 --- a/packages/client/src/components/Profile/UserCard.tsx +++ b/packages/client/src/components/Profile/UserCard.tsx @@ -1,10 +1,12 @@ import { faMessage, faWarning } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Button, Link, Spinner, User } from "@nextui-org/react"; +import { Button, Link, Skeleton, Spinner, User } from "@nextui-org/react"; import { ClientConfig } from "@sc07-canvas/lib/src/net"; -import { MouseEvent, useEffect, useState } from "react"; +import { MouseEvent, useEffect, useRef, useState } from "react"; import { toast } from "react-toastify"; import { useAppContext } from "../../contexts/AppContext"; +import { useHasRole } from "../../hooks/useHasRole"; +import { useModerator } from "../../Moderator/Moderator"; export interface IUser { sub: string; @@ -20,12 +22,37 @@ const getMatrixLink = (user: IUser, config: ClientConfig) => { return `${config.chat.element_host}/#/user/@${user.sub.replace("@", "=40")}:${config.chat.matrix_homeserver}`; }; +const HighlightHostname = ({ url }: { url: string }) => { + const parsed = useRef(undefined); + + useEffect(() => { + try { + parsed.current = new URL(url); + } catch (_e) {} + }, [url]); + + if (!parsed.current) return <>{url}; + + return ( + <> + {parsed.current.protocol}://{parsed.current.host} + {parsed.current.pathname} + + ); +}; + /** * Small UserCard that shows profile picture, display name, full username, message box and (currently unused) view profile button * @param param0 * @returns */ -export const UserCard = ({ user }: { user: IUser }) => { +export const UserCard = ({ + user, + hide = [], +}: { + user?: IUser; + hide?: ("MOD_ACTIONS" | "VIEW_PROFILE")[]; +}) => { const { config, setProfile } = useAppContext(); const [messageStatus, setMessageStatus] = useState< "loading" | "no_account" | "has_account" | "error" @@ -37,6 +64,10 @@ export const UserCard = ({ user }: { user: IUser }) => { return; } + if (!user) { + return; + } + setMessageStatus("loading"); fetch( @@ -70,15 +101,27 @@ export const UserCard = ({ user }: { user: IUser }) => { }; const openProfile = () => { - setProfile(user.sub); + setProfile(user?.sub); }; + if (!user) { + return ( +
    +
    + + + +
    +
    + ); + } + return (
    } avatarProps={{ showFallback: true, name: undefined, @@ -106,9 +149,32 @@ export const UserCard = ({ user }: { user: IUser }) => { )}
    - +
    + {hide.indexOf("VIEW_PROFILE") === -1 && ( + + )} + {hide.indexOf("MOD_ACTIONS") === -1 && } +
    ); }; + +const ModActions = ({ sub }: { sub: string }) => { + const { dispatch } = useModerator(); + const isMod = useHasRole("MOD"); + + if (!isMod) return <>; + + return ( + <> + + + ); +}; diff --git a/packages/client/src/components/SidebarBase.tsx b/packages/client/src/components/SidebarBase.tsx index 9458c24..a186c49 100644 --- a/packages/client/src/components/SidebarBase.tsx +++ b/packages/client/src/components/SidebarBase.tsx @@ -20,26 +20,30 @@ export const SidebarBase = ({ title, description, side, + noBackdrop, }: { children: string | JSX.Element | JSX.Element[]; icon: IconProp; shown: boolean; setSidebarShown: (value: boolean) => void; title: string; - description: string; + description?: string; side: "Left" | "Right"; + noBackdrop?: boolean; }) => { return (
    - + {!noBackdrop && ( + + )}

    {title}

    -

    {description}

    + {description && ( +

    {description}

    + )}
    diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx index 942f993..da910fb 100644 --- a/packages/client/src/contexts/AppContext.tsx +++ b/packages/client/src/contexts/AppContext.tsx @@ -8,11 +8,15 @@ import React, { import { AuthSession, ClientConfig } from "@sc07-canvas/lib/src/net"; import Network from "../lib/network"; import { Spinner } from "@nextui-org/react"; -import { api } from "../lib/utils"; +import { oapi } from "../lib/utils"; +import { RestAPI } from "@sc07-canvas/lib"; + +type Whoami = RestAPI.components["schemas"]["Whoami"]; interface IAppContext { config?: ClientConfig; user?: AuthSession; + whoami?: Whoami; connected: boolean; canvasPosition?: ICanvasPosition; @@ -43,10 +47,6 @@ interface IAppContext { profile?: string; // sub setProfile: (v?: string) => void; - - hasAdmin: boolean; - showModModal: boolean; - setShowModModal: React.Dispatch>; } interface ICanvasPosition { @@ -96,6 +96,7 @@ export const useAppContext = () => export const AppContext = ({ children }: PropsWithChildren) => { const [config, setConfig] = useState(undefined as any); const [auth, setAuth] = useState(); + const [whoami, setWhoami] = useState(); const [canvasPosition, setCanvasPosition] = useState(); const [cursor, setCursor] = useState({}); const [connected, setConnected] = useState(false); @@ -130,10 +131,11 @@ export const AppContext = ({ children }: PropsWithChildren) => { const [profile, setProfile] = useState(); - const [hasAdmin, setHasAdmin] = useState(false); - const [showModModal, setShowModModal] = useState(false); - useEffect(() => { + oapi.GET("/whoami").then(({ data }) => { + setWhoami(data); + }); + function loadSettings() { setLoadChat( localStorage.getItem("matrix.enable") === null @@ -172,14 +174,6 @@ export const AppContext = ({ children }: PropsWithChildren) => { setConnected(false); } - api<{}>("/api/admin/check").then(({ status, data }) => { - if (status === 200) { - if (data.success) { - setHasAdmin(true); - } - } - }); - Network.on("user", handleUser); Network.on("config", handleConfig); Network.waitForState("pixels").then(([data]) => handlePixels(data)); @@ -213,6 +207,7 @@ export const AppContext = ({ children }: PropsWithChildren) => { value={{ config, user: auth, + whoami, canvasPosition, setCanvasPosition, cursor, @@ -224,7 +219,6 @@ export const AppContext = ({ children }: PropsWithChildren) => { loadChat, setLoadChat, connected, - hasAdmin, pixelWhois, setPixelWhois, showKeybinds, @@ -239,8 +233,6 @@ export const AppContext = ({ children }: PropsWithChildren) => { setProfile, infoSidebar, setInfoSidebar, - showModModal, - setShowModModal, }} > {!config && ( diff --git a/packages/client/src/hooks/useHasRole.ts b/packages/client/src/hooks/useHasRole.ts new file mode 100644 index 0000000..0c4faaa --- /dev/null +++ b/packages/client/src/hooks/useHasRole.ts @@ -0,0 +1,13 @@ +import { useAppContext } from "../contexts/AppContext"; + +const AUTH_MAP = { + USER: "user", + MOD: "mod", + ADMIN: "admin", +} as const; + +export const useHasRole = (role: "USER" | "MOD" | "ADMIN") => { + const { whoami } = useAppContext(); + + return Boolean(whoami?.roles[AUTH_MAP[role]]); +}; diff --git a/packages/client/src/lib/keybinds.ts b/packages/client/src/lib/keybinds.ts index e337df4..2d46de9 100644 --- a/packages/client/src/lib/keybinds.ts +++ b/packages/client/src/lib/keybinds.ts @@ -1,7 +1,7 @@ import EventEmitter from "eventemitter3"; import { EnforceObjectType } from "./utils"; -interface IKeybind { +export interface IKeybind { key: KeyboardEvent["code"] | "LCLICK" | "RCLICK" | "MCLICK" | "LONG_PRESS"; alt?: boolean; @@ -64,6 +64,12 @@ const KEYBINDS = enforceObjectType({ key: "MCLICK", }, ], + MOD_SELECT: [ + { + key: "LCLICK", + ctrl: true, + }, + ], }); class KeybindManager_ extends EventEmitter<{ diff --git a/packages/client/src/lib/utils.ts b/packages/client/src/lib/utils.ts index 0420cba..bcc5acb 100644 --- a/packages/client/src/lib/utils.ts +++ b/packages/client/src/lib/utils.ts @@ -2,7 +2,8 @@ import { toast } from "react-toastify"; import createClient from "openapi-fetch"; import { Renderer } from "./renderer"; import { Debug } from "@sc07-canvas/lib/src/debug"; -import type { RestAPI } from "@sc07-canvas/lib"; +import type { EError, RestAPI } from "@sc07-canvas/lib"; +import { createQueryHook } from "swr-openapi"; let _renderer: Renderer; @@ -37,6 +38,8 @@ export const oapi = createClient({ baseUrl: new URL("/api", window.location.origin).toString(), }); +export const useQuery = createQueryHook(oapi, "api"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const api = async ( endpoint: string, @@ -82,9 +85,22 @@ export type EnforceObjectType = ( v: V ) => { [k in keyof V]: T }; -export const handleError = (api_response: Awaited>) => { +type APIResponse = Awaited>; + +interface HandleError { + (error: EError | Error): void; + (api_response: APIResponse): void; +} + +export const handleError: HandleError = (error) => { + if (error instanceof Error) { + console.error(error); + toast.error(error.toString()); + return; + } + toast.error( - `Error: [${api_response.status}] ` + - ("error" in api_response.data ? api_response.data.error : "Unknown Error") + `Error: [${error.status}] ` + + ("error" in error.data ? error.data.error : "Unknown Error") ); }; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 1a52ab5..099b7e3 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -3,3 +3,32 @@ import { CanvasLib } from "./canvas"; import type * as RestAPI from "./api-schema"; export { net, CanvasLib, RestAPI }; + +/** + * Expanded Error + */ +export class EError extends Error { + readonly status: number; + + constructor(message?: string); + constructor( + message?: RestAPI.components["schemas"]["Error"], + status?: number + ); + constructor( + message?: string | RestAPI.components["schemas"]["Error"], + status = 500 + ) { + let initializer: string | undefined; + if (typeof message === "string") initializer = message; + if (typeof message === "object") + initializer = message.error_message || message.error; + super(initializer); + + this.status = status; + } + + override toString() { + return `Error: [${this.status}] ${this.message || "Unknown Error"}`; + } +} diff --git a/packages/server/src/api/client/auth.ts b/packages/server/src/api/client/auth.ts index 9e81554..9446ce2 100644 --- a/packages/server/src/api/client/auth.ts +++ b/packages/server/src/api/client/auth.ts @@ -2,6 +2,7 @@ import { InvalidAuthMode, OpenIDController, } from "../../controllers/OpenIDController"; +import { User } from "../../models/User"; import { Router } from "../lib/router"; export class AuthEndpoints extends Router { @@ -79,6 +80,20 @@ export class AuthEndpoints extends Router { }); } + @Router.handler("get", "/whoami") + async getWhoami(req: Router.Request, res: Router.Response) { + const user = req.session.user + ? await User.fromAuthSession(req.session.user) + : null; + res.json({ + roles: { + user: Boolean(req.session.user), + mod: Boolean(user?.isModerator), + admin: Boolean(user?.isAdmin), + }, + }); + } + @Router.handler("get", "/session", ["development"]) debug_getSession(req: Router.Request, res: Router.Response) { res.json({ diff --git a/packages/server/src/api/mod/canvas.ts b/packages/server/src/api/mod/canvas.ts index 6a81733..886f400 100644 --- a/packages/server/src/api/mod/canvas.ts +++ b/packages/server/src/api/mod/canvas.ts @@ -9,6 +9,47 @@ import { Router } from "../lib/router"; const Logger = getLogger("HTTP/ADMIN"); export class CanvasModEndpoints extends Router { + @Router.handler("get", "/query") + async queryPixels(req: Router.Request, res: Router.Response) { + if (typeof req.query.from !== "string") { + res.status(400).json({ error: "?from is not a string" }); + return; + } + + if (req.query.from.split(",").length < 2) { + res.status(400).json({ error: "?from is not a comma separated x,y" }); + return; + } + + if (typeof req.query.to !== "string") { + res.status(400).json({ error: "?to is not a string" }); + return; + } + + if (req.query.to.split(",").length < 2) { + res.status(400).json({ error: "?to is not a comma separated x,y" }); + return; + } + + const from = req.query.from.split(",").map((v) => parseInt(v)); + const to = req.query.to.split(",").map((v) => parseInt(v)); + + const pixels = await prisma.pixel.findMany({ + where: { + x: { gte: from[0], lte: to[0] }, + y: { gte: from[1], lte: to[1] }, + }, + include: { + user: true, + PixelReport: true, + }, + }); + + res.json({ + pixels, + }); + } + /** * Undo a square * @@ -18,7 +59,7 @@ export class CanvasModEndpoints extends Router { * @body end.x number * @body end.y number */ - @Router.handler("put", "/undo") + @Router.handler("post", "/undo") async undoSquare(req: Router.Request, res: Router.Response) { if ( typeof req.body?.start?.x !== "number" || diff --git a/packages/server/src/api/mod/index.ts b/packages/server/src/api/mod/index.ts index c4b367c..133cef5 100644 --- a/packages/server/src/api/mod/index.ts +++ b/packages/server/src/api/mod/index.ts @@ -5,6 +5,7 @@ import { InstancesEndpoints } from "./instances"; import { IPEndpoints } from "./ips"; import { UsersEndpoints } from "./users"; +@Router.requireAuth("MOD", true) export class ModeratorEndpoints extends Router { constructor(...args: any[]) { super(...args); diff --git a/packages/server/src/api/openapi.yml b/packages/server/src/api/openapi.yml index 591e050..95dd776 100644 --- a/packages/server/src/api/openapi.yml +++ b/packages/server/src/api/openapi.yml @@ -9,6 +9,16 @@ servers: - url: http://localhost:3000 security: [] paths: + /whoami: + get: + summary: Get user details + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/Whoami" /canvas/pixel/{x}/{y}: parameters: - in: path @@ -82,6 +92,75 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /mod/canvas/query: + parameters: + - name: from + in: query + required: true + schema: + type: string + format: /\d+,\d+/g + - name: to + in: query + required: true + schema: + type: string + format: /\d+,\d+/g + get: + summary: Query pixels + responses: + 200: + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/ModPixelQuery" + /mod/canvas/undo: + post: + summary: Undo a square + requestBody: + content: + application/json: + schema: + type: object + required: + - start + - end + properties: + start: + type: object + description: inclusive + required: + - x + - y + properties: + x: + type: number + y: + type: number + end: + type: object + description: inclusive + required: + - x + - y + properties: + x: + type: number + y: + type: number + responses: + 200: + description: Success + content: + application/json: + schema: + type: object + required: + - auditLog + properties: + auditLog: + $ref: "#/components/schemas/AuditLog" /mod/user: description: Query all users /mod/user/@all/notice: @@ -277,6 +356,66 @@ components: type: string example: grants.cafe schemas: + ModPixelQuery: + type: object + required: + - pixels + properties: + pixels: + type: array + items: + allOf: + - $ref: "#/components/schemas/Pixel" + - required: + - user + - PixelReport + properties: + user: + $ref: "#/components/schemas/SparseUser" + PixelReport: + type: array + items: + $ref: "#/components/schemas/ModPixelReport" + ModPixelReport: + type: object + required: + - id + - rules + properties: + id: + type: number + pixelId: + type: number + reporterSub: + type: string + rules: + type: array + comment: + type: string + status: + type: string + enum: + - NEW + - RESOLVED + - REJECTED + Whoami: + type: object + required: + - roles + properties: + roles: + type: object + required: + - user + - mod + - admin + properties: + user: + type: boolean + mod: + type: boolean + admin: + type: boolean Error: type: object required: diff --git a/packages/server/src/controllers/CanvasController.ts b/packages/server/src/controllers/CanvasController.ts index 8d90d57..6df2e26 100644 --- a/packages/server/src/controllers/CanvasController.ts +++ b/packages/server/src/controllers/CanvasController.ts @@ -363,8 +363,8 @@ export class CanvasController { /** * Undo an area of pixels - * @param start - * @param end + * @param start inclusive + * @param end inclusive * @returns */ async undoArea(start: [x: number, y: number], end: [x: number, y: number]) { @@ -375,11 +375,11 @@ export class CanvasController { where: { x: { gte: start[0], - lt: end[0], + lte: end[0], }, y: { gte: start[1], - lt: end[1], + lte: end[1], }, isTop: true, }, -- GitLab