diff --git a/package-lock.json b/package-lock.json
index 18785e5dcb252ec8dcbcea27387acc8856eb2ddf..b004d4483c279582f5a2c07f3451106462dc7e6c 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 566042378e86419a9852d568eb5fda57777737b5..dce9c590ac36690055c741748722fe91a5327547 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 0000000000000000000000000000000000000000..8d3ec4585a41769a61298f2b62334e4411020f4b
--- /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 0000000000000000000000000000000000000000..f19e993fc084d9212bfc171d17ff039fa51efa08
--- /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 0000000000000000000000000000000000000000..31f5275dd601f013c4debd042c2317f7f7c8255c
--- /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 0000000000000000000000000000000000000000..6d2035cded3b22b8261c2c5ce0c7e8518f5efa94
--- /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 e3e188e136c59aefc0f9fdea3c8685c0a4a83890..0c78abf33e5fd5deed2fc2f8bbfaf23bbf1d5262 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 3a0762377f7947a5df87db8afcf4be5893f976ed..a310270e676e6e2f378698e3c87911ef1a75590f 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 30f681a9c28aeb1852399a40372725c3bd6dd6ba..b3d2707646fc88c61e37b502a040f99a88385c68 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 && (
-
-
+
+ {whois?.user && (
+ <>
+
+ >
+ )}
+
diff --git a/packages/client/src/components/Profile/ProfileModal.tsx b/packages/client/src/components/Profile/ProfileModal.tsx
index 8e4b84c0d689839b92fa888ea80c27951851e8af..4899865de5c9003634a39235ddf6084e8c5c210a 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...>}
- test get ips
+ {user ? (
+
+ ) : (
+ <>Loading...>
+ )}
Close
diff --git a/packages/client/src/components/Profile/UserCard.tsx b/packages/client/src/components/Profile/UserCard.tsx
index f2831b9527c02e26a89b7070fb30ae3888ea87a9..da10cdee24c579bcaaf8b5a0cfc4f4c5733970bf 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 }) => {
)}
-
- View Profile
-
+
+ {hide.indexOf("VIEW_PROFILE") === -1 && (
+
+ View Profile
+
+ )}
+ {hide.indexOf("MOD_ACTIONS") === -1 && }
+
);
};
+
+const ModActions = ({ sub }: { sub: string }) => {
+ const { dispatch } = useModerator();
+ const isMod = useHasRole("MOD");
+
+ if (!isMod) return <>>;
+
+ return (
+ <>
+ dispatch({ action: "INSPECT_USER", user: sub })}
+ >
+ Mod Menu
+
+ >
+ );
+};
diff --git a/packages/client/src/components/SidebarBase.tsx b/packages/client/src/components/SidebarBase.tsx
index 9458c2404bdd87d407b75a900e3304c5f838fd9a..a186c49b0f69fea885245e44a38e71d9f64df305 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 942f993581211c8b1c4a52f3ecc5d3666d969733..da910fbe1613e192ccbbb8e12363b20da04f2fd4 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 0000000000000000000000000000000000000000..0c4faaa09a4eb16d848f72ec08dd6984dbe79150
--- /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 e337df4d5a0643f1bb0f29cdc516bd2e8f8eedaf..2d46de9c7d66071b8fdd0f9d7dbf4df2157c723d 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 0420cba89889ef22566d7388290a1b6df5dd455a..bcc5acb04a58eb5a7bd325c01a5ff87e929b65a0 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 1a52ab5cf61a39887c2505fe907e3f1d912cebc9..099b7e39cc27451094f5a2d8376a744c18d3e9c6 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 9e815546d8814f32eaf44f05a1cd4d4d797d667c..9446ce2e2db95c1774dd4c79a420184424b0f888 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 6a817330c0b3a93a3d820d92e57736b7306aa37d..886f400840f5ddb584bd071deaa5e560f13ae942 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 c4b367cb6fec8adecc6f1e91386f2bcb6ed532bd..133cef53fd22d85221de6431006cc2ece68f6c89 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 591e0504d8dcf9ba4efcf40804546e273b929f18..95dd77645d91456cca68fe65aa96c8744a0d8cb8 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 8d90d57916ac16995c28295a17c72d4d69603da9..6df2e265103ed34739c1f44513d215f229a0be3d 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,
},