From c134545bab91eb6133ea4c70971650bb7da4f81e Mon Sep 17 00:00:00 2001 From: DragonSenseiGuy <200907890+DragonSenseiGuy@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:59:13 -0500 Subject: [PATCH 01/19] Add the admin dashboard --- apps/web/.env.example | 2 +- apps/web/etc/scripts/trim-long-handles.js | 107 ++++ .../migration.sql | 5 + apps/web/prisma/{promote.mjs => promote.ts} | 20 +- apps/web/prisma/schema.prisma | 35 ++ apps/web/src/__tests__/comments.test.ts | 64 ++- apps/web/src/__tests__/factories.ts | 21 +- apps/web/src/__tests__/user.test.ts | 219 +++++++ apps/web/src/client/context/AuthContext.tsx | 42 +- apps/web/src/client/trpc.ts | 18 +- apps/web/src/pages/_app.tsx | 30 +- apps/web/src/pages/admin/index.tsx | 541 ++++++++++++++++++ apps/web/src/pages/banned.tsx | 35 ++ apps/web/src/server/routers/api/comment.ts | 13 +- apps/web/src/server/routers/api/user.ts | 309 +++++++++- apps/web/src/server/trpc.ts | 7 + apps/web/tsconfig.json | 3 +- package.json | 2 +- 18 files changed, 1436 insertions(+), 37 deletions(-) create mode 100644 apps/web/etc/scripts/trim-long-handles.js create mode 100644 apps/web/prisma/migrations/20260113000000_add_user_ban_fields/migration.sql rename apps/web/prisma/{promote.mjs => promote.ts} (70%) create mode 100644 apps/web/src/pages/admin/index.tsx create mode 100644 apps/web/src/pages/banned.tsx diff --git a/apps/web/.env.example b/apps/web/.env.example index 008ba330..2e57ce51 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -15,7 +15,7 @@ NEXT_PUBLIC_HACKATIME_URL=https://hackatime.hackclub.com HACKATIME_REDIRECT_URI=http://localhost:3000/api/auth-hackatime # Slack bot token used to fetch user profile information (including profile pictures). -# Create a Slack app, enable the "users:read" scope, and generate a bot token. +# Create a Slack app, enable the "users:read" scope, and generate a bot token. The toke will be in the format `xoxb-.....` SLACK_BOT_TOKEN= # We use S3 to store all user content (timelapses, thumbnails, etc...) - if you don't have an S3 bucket at hand, you can set up diff --git a/apps/web/etc/scripts/trim-long-handles.js b/apps/web/etc/scripts/trim-long-handles.js new file mode 100644 index 00000000..0f34f520 --- /dev/null +++ b/apps/web/etc/scripts/trim-long-handles.js @@ -0,0 +1,107 @@ +// @ts-check +"use strict"; + +import { parseArgs } from "node:util"; + +import { confirm } from "@inquirer/prompts"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../../src/generated/prisma/client.js"; + +const MAX_HANDLE_LENGTH = 16; + +async function main() { + const args = parseArgs({ + options: { + "database-url": { type: "string" }, + "dry-run": { type: "boolean", default: false } + } + }); + + console.log(""); + + const databaseUrl = args.values["database-url"]; + const dryRun = args.values["dry-run"] ?? false; + + if (!databaseUrl) { + console.error("(error) Missing required parameter: --database-url"); + return; + } + + const adapter = new PrismaPg({ connectionString: databaseUrl }); + const prisma = new PrismaClient({ adapter }); + + try { + console.log("(info) Finding users with handles longer than 16 characters..."); + + const usersWithLongHandles = await prisma.user.findMany({ + where: { + handle: { + not: { + // Prisma doesn't support length filters directly, so we fetch all and filter + } + } + }, + select: { id: true, handle: true, displayName: true } + }); + + const affectedUsers = usersWithLongHandles.filter(user => user.handle.length > MAX_HANDLE_LENGTH); + + if (affectedUsers.length === 0) { + console.log("(info) No users found with handles longer than 16 characters. Nothing to do."); + return; + } + + console.log(`(info) Found ${affectedUsers.length} user(s) with handles longer than 16 characters:`); + console.log(""); + + for (const user of affectedUsers) { + const trimmedHandle = user.handle.substring(0, MAX_HANDLE_LENGTH); + console.log(` - [${user.id}] "${user.handle}" (${user.handle.length} chars) -> "${trimmedHandle}"`); + } + + console.log(""); + + if (dryRun) { + console.log("(info) Dry run mode. No changes were made."); + return; + } + + if (!await confirm({ message: `Do you wish to trim ${affectedUsers.length} handle(s)? (Y/N)` })) { + console.log("(info) Aborted. No changes were made."); + return; + } + + let successCount = 0; + let failureCount = 0; + + for (const user of affectedUsers) { + const trimmedHandle = user.handle.substring(0, MAX_HANDLE_LENGTH); + + try { + await prisma.user.update({ + where: { id: user.id }, + data: { handle: trimmedHandle } + }); + + console.log(`(info) [${user.id}] Handle updated: "${user.handle}" -> "${trimmedHandle}"`); + successCount++; + } + catch (error) { + console.error(`(error) [${user.id}] Failed to update handle:`, error); + failureCount++; + } + } + + console.log(""); + console.log(`(info) Completed. ${successCount} handle(s) trimmed, ${failureCount} failure(s).`); + } + finally { + await prisma.$disconnect(); + } +} + +main() + .catch(async (e) => { + console.error(e); + process.exit(1); + }); \ No newline at end of file diff --git a/apps/web/prisma/migrations/20260113000000_add_user_ban_fields/migration.sql b/apps/web/prisma/migrations/20260113000000_add_user_ban_fields/migration.sql new file mode 100644 index 00000000..e5bf1132 --- /dev/null +++ b/apps/web/prisma/migrations/20260113000000_add_user_ban_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "isBanned" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "User" ADD COLUMN "bannedAt" TIMESTAMP(3); +ALTER TABLE "User" ADD COLUMN "bannedReason" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "User" ADD COLUMN "bannedReasonInternal" TEXT NOT NULL DEFAULT ''; diff --git a/apps/web/prisma/promote.mjs b/apps/web/prisma/promote.ts similarity index 70% rename from apps/web/prisma/promote.mjs rename to apps/web/prisma/promote.ts index a400e79b..ee2fd534 100644 --- a/apps/web/prisma/promote.mjs +++ b/apps/web/prisma/promote.ts @@ -6,7 +6,7 @@ import { parseArgs } from "node:util"; import { confirm } from "@inquirer/prompts"; import { PrismaPg } from "@prisma/adapter-pg"; -import { PrismaClient } from "../src/generated/prisma/client.js"; +import { PrismaClient } from "../src/generated/prisma/client"; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter }); @@ -14,22 +14,30 @@ async function main() { const args = parseArgs({ options: { email: { type: "string" } - } + }, + allowPositionals: true }); console.log(""); - if (!args.values.email) { - console.error("(error) No e-mail specified. Aborting."); + const identifier = args.values.email || args.positionals[0]; + + if (!identifier) { + console.error("(error) No e-mail or handle specified. Usage: node promote.mjs "); return; } const user = await prisma.user.findFirst({ - where: { email: args.values.email } + where: { + OR: [ + { email: identifier }, + { handle: identifier } + ] + } }); if (!user) { - console.error(`(error) No user with e-mail ${args.values.email} exists!`); + console.error(`(error) No user with e-mail or handle "${identifier}" exists!`); return; } diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 2dab4b51..8dc2f15d 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -52,11 +52,46 @@ model User { slackId String? lastHeartbeat DateTime @default(now()) + isBanned Boolean @default(false) + bannedAt DateTime? + bannedReason String @default("") + bannedReasonInternal String @default("") + timelapses Timelapse[] devices KnownDevice[] postedComments Comment[] uploadTokens UploadToken[] draftTimelapses DraftTimelapse[] + banRecords BanRecord[] @relation("BanRecordTarget") + performedBanRecords BanRecord[] @relation("BanRecordPerformer") +} + +enum BanAction { + BAN + UNBAN +} + +/// Represents a single ban or unban action performed on a user. +model BanRecord { + id String @id @default(nanoid(12)) + createdAt DateTime @default(now()) + + /// The action that was performed. + action BanAction + + /// The public reason for the action (shown to user). + reason String @default("") + + /// The internal reason for the action (only visible to admins). + reasonInternal String @default("") + + /// The user that was banned or unbanned. + targetId String + target User @relation("BanRecordTarget", fields: [targetId], references: [id], onDelete: Cascade) + + /// The admin who performed the action. + performedById String + performedBy User @relation("BanRecordPerformer", fields: [performedById], references: [id], onDelete: Cascade) } /// Represents a timelapse that has not yet been uploaded to the server. diff --git a/apps/web/src/__tests__/comments.test.ts b/apps/web/src/__tests__/comments.test.ts index 7281bbea..3954f241 100644 --- a/apps/web/src/__tests__/comments.test.ts +++ b/apps/web/src/__tests__/comments.test.ts @@ -182,12 +182,17 @@ describe("comment router", () => { it("returns NO_PERMISSION when deleting another user's comment", async () => { const user = testFactory.user({ id: "user-1" }); const other = testFactory.user({ id: "user-2" }); + const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" }); const commentEntity = testFactory.comment({ id: "comment-1", authorId: other.id, + timelapseId: timelapse.id, }); - mockDatabase.comment.findUnique.mockResolvedValueOnce(commentEntity); + mockDatabase.comment.findUnique.mockResolvedValueOnce({ + ...commentEntity, + timelapse, + }); const caller = createCaller(createMockContext(user)); const result = await caller.delete({ commentId: commentEntity.id }); @@ -199,14 +204,69 @@ describe("comment router", () => { expect(mockDatabase.comment.delete).not.toHaveBeenCalled(); }); + it("allows admin to delete any comment", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const other = testFactory.user({ id: "user-2" }); + const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" }); + const commentEntity = testFactory.comment({ + id: "comment-1", + authorId: other.id, + timelapseId: timelapse.id, + }); + + mockDatabase.comment.findUnique.mockResolvedValueOnce({ + ...commentEntity, + timelapse, + }); + mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity); + + const caller = createCaller(createMockContext(admin)); + const result = await caller.delete({ commentId: commentEntity.id }); + + expect(result.ok).toBe(true); + expect(mockDatabase.comment.delete).toHaveBeenCalledWith({ + where: { id: commentEntity.id }, + }); + }); + + it("allows timelapse owner to delete comments on their timelapse", async () => { + const owner = testFactory.user({ id: "owner-1" }); + const commenter = testFactory.user({ id: "user-2" }); + const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: owner.id }); + const commentEntity = testFactory.comment({ + id: "comment-1", + authorId: commenter.id, + timelapseId: timelapse.id, + }); + + mockDatabase.comment.findUnique.mockResolvedValueOnce({ + ...commentEntity, + timelapse, + }); + mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity); + + const caller = createCaller(createMockContext(owner)); + const result = await caller.delete({ commentId: commentEntity.id }); + + expect(result.ok).toBe(true); + expect(mockDatabase.comment.delete).toHaveBeenCalledWith({ + where: { id: commentEntity.id }, + }); + }); + it("deletes owned comment", async () => { const user = testFactory.user({ id: "user-1" }); + const timelapse = testFactory.timelapse({ id: "timelapse-1", ownerId: "owner-1" }); const commentEntity = testFactory.comment({ id: "comment-1", authorId: user.id, + timelapseId: timelapse.id, }); - mockDatabase.comment.findUnique.mockResolvedValueOnce(commentEntity); + mockDatabase.comment.findUnique.mockResolvedValueOnce({ + ...commentEntity, + timelapse, + }); mockDatabase.comment.delete.mockResolvedValueOnce(commentEntity); const caller = createCaller(createMockContext(user)); diff --git a/apps/web/src/__tests__/factories.ts b/apps/web/src/__tests__/factories.ts index 124867ba..4f813244 100644 --- a/apps/web/src/__tests__/factories.ts +++ b/apps/web/src/__tests__/factories.ts @@ -7,8 +7,9 @@ import type { Comment, UploadToken, DraftTimelapse, + BanRecord, } from "@/generated/prisma/client"; -import { PermissionLevel, TimelapseVisibility, VideoContainerKind } from "@/generated/prisma/client"; +import { PermissionLevel, TimelapseVisibility, VideoContainerKind, BanAction } from "@/generated/prisma/client"; /** * Generates a Nano ID-like string (12 characters). @@ -39,6 +40,10 @@ export const testFactory = { hackatimeAccessToken: overrides.hackatimeAccessToken ?? null, lastHeartbeat: faker.date.recent(), hackatimeRefreshToken: overrides?.hackatimeRefreshToken ?? null, + isBanned: false, + bannedAt: null, + bannedReason: "", + bannedReasonInternal: "", ...overrides, }), @@ -122,4 +127,18 @@ export const testFactory = { thumbnailTokenId: faker.string.uuid(), ...overrides, }), + + /** + * Creates a mock BanRecord object. + */ + banRecord: (overrides: Partial = {}): BanRecord => ({ + id: nanoid(12), + createdAt: faker.date.recent(), + action: BanAction.BAN, + reason: faker.lorem.sentence(), + reasonInternal: faker.lorem.sentence(), + targetId: nanoid(12), + performedById: nanoid(12), + ...overrides, + }), }; diff --git a/apps/web/src/__tests__/user.test.ts b/apps/web/src/__tests__/user.test.ts index 7a2f8641..6aee1b61 100644 --- a/apps/web/src/__tests__/user.test.ts +++ b/apps/web/src/__tests__/user.test.ts @@ -436,4 +436,223 @@ describe("user router", () => { }); }); }); + + describe("setBanStatus", () => { + it("should deny normal users from banning", async () => { + const user = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + const target = testFactory.user({ id: "user-2", permissionLevel: "USER" }); + + const caller = await createCaller(createMockContext(user)); + const result = await caller.setBanStatus({ + id: target.id, + isBanned: true, + reason: "Test ban", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should allow admin to ban a user", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const target = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + const bannedTarget = { + ...target, + isBanned: true, + bannedAt: new Date(), + bannedReason: "Test ban", + devices: [], + }; + + mockDatabase.user.findFirst.mockResolvedValue({ ...target, devices: [] }); + mockDatabase.user.update.mockResolvedValue(bannedTarget); + + const caller = await createCaller(createMockContext(admin)); + const result = await caller.setBanStatus({ + id: target.id, + isBanned: true, + reason: "Test ban", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.user.private.isBanned).toBe(true); + } + }); + + it("should prevent admin from banning another admin", async () => { + const admin1 = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const admin2 = testFactory.user({ id: "admin-2", permissionLevel: "ADMIN" }); + + mockDatabase.user.findFirst.mockResolvedValue({ ...admin2, devices: [] }); + + const caller = await createCaller(createMockContext(admin1)); + const result = await caller.setBanStatus({ + id: admin2.id, + isBanned: true, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should allow ROOT to ban an admin", async () => { + const root = testFactory.user({ id: "root-1", permissionLevel: "ROOT" }); + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const bannedAdmin = { + ...admin, + isBanned: true, + bannedAt: new Date(), + bannedReason: "Test ban", + devices: [], + }; + + mockDatabase.user.findFirst.mockResolvedValue({ ...admin, devices: [] }); + mockDatabase.user.update.mockResolvedValue(bannedAdmin); + + const caller = await createCaller(createMockContext(root)); + const result = await caller.setBanStatus({ + id: admin.id, + isBanned: true, + reason: "Test ban", + }); + + expect(result.ok).toBe(true); + }); + + it("should prevent user from banning themselves", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + + mockDatabase.user.findFirst.mockResolvedValue({ ...admin, devices: [] }); + + const caller = await createCaller(createMockContext(admin)); + const result = await caller.setBanStatus({ + id: admin.id, + isBanned: true, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + }); + + describe("list", () => { + it("should deny normal users from listing users", async () => { + const user = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + + const caller = await createCaller(createMockContext(user)); + const result = await caller.list({ limit: 10 }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should allow admin to list users", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const users = [ + { ...testFactory.user({ id: "user-1" }), devices: [] }, + { ...testFactory.user({ id: "user-2" }), devices: [] }, + ]; + + mockDatabase.user.findMany.mockResolvedValue(users); + + const caller = await createCaller(createMockContext(admin)); + const result = await caller.list({ limit: 10 }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.users).toHaveLength(2); + } + }); + + it("should filter banned users when onlyBanned is true", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const bannedUser = { ...testFactory.user({ id: "user-1", isBanned: true }), devices: [] }; + + mockDatabase.user.findMany.mockResolvedValue([bannedUser]); + + const caller = await createCaller(createMockContext(admin)); + const result = await caller.list({ limit: 10, onlyBanned: true }); + + expect(result.ok).toBe(true); + expect(mockDatabase.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { isBanned: true }, + }) + ); + }); + }); + + describe("deleteUser", () => { + it("should deny admin from deleting users", async () => { + const admin = testFactory.user({ id: "admin-1", permissionLevel: "ADMIN" }); + const target = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + + const caller = await createCaller(createMockContext(admin)); + const result = await caller.deleteUser({ id: target.id }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should allow ROOT to delete a user", async () => { + const root = testFactory.user({ id: "root-1", permissionLevel: "ROOT" }); + const target = testFactory.user({ id: "user-1", permissionLevel: "USER" }); + + mockDatabase.user.findFirst.mockResolvedValue(target); + mockDatabase.timelapse.findMany.mockResolvedValue([]); + mockDatabase.comment.deleteMany.mockResolvedValue({ count: 0 }); + mockDatabase.knownDevice.deleteMany.mockResolvedValue({ count: 0 }); + mockDatabase.uploadToken.deleteMany.mockResolvedValue({ count: 0 }); + mockDatabase.draftTimelapse.deleteMany.mockResolvedValue({ count: 0 }); + mockDatabase.user.delete.mockResolvedValue(target); + + const caller = await createCaller(createMockContext(root)); + const result = await caller.deleteUser({ id: target.id }); + + expect(result.ok).toBe(true); + expect(mockDatabase.user.delete).toHaveBeenCalledWith({ + where: { id: target.id }, + }); + }); + + it("should prevent ROOT from deleting themselves", async () => { + const root = testFactory.user({ id: "root-1", permissionLevel: "ROOT" }); + + mockDatabase.user.findFirst.mockResolvedValue(root); + + const caller = await createCaller(createMockContext(root)); + const result = await caller.deleteUser({ id: root.id }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + + it("should prevent deleting ROOT users", async () => { + const root1 = testFactory.user({ id: "root-1", permissionLevel: "ROOT" }); + const root2 = testFactory.user({ id: "root-2", permissionLevel: "ROOT" }); + + mockDatabase.user.findFirst.mockResolvedValue(root2); + + const caller = await createCaller(createMockContext(root1)); + const result = await caller.deleteUser({ id: root2.id }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("NO_PERMISSION"); + } + }); + }); }); diff --git a/apps/web/src/client/context/AuthContext.tsx b/apps/web/src/client/context/AuthContext.tsx index 6c6b4d5b..53847685 100644 --- a/apps/web/src/client/context/AuthContext.tsx +++ b/apps/web/src/client/context/AuthContext.tsx @@ -2,13 +2,15 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from import { useRouter } from "next/router"; import type { User } from "@/client/api"; -import { trpc } from "@/client/trpc"; +import { trpc, handleBanError } from "@/client/trpc"; import { useOnce } from "@/client/hooks/useOnce"; import { useCache } from "@/client/hooks/useCache"; interface AuthContextValue { currentUser: User | null; isLoading: boolean; + isBanned: boolean; + banReason: string | null; signOut: () => Promise; } @@ -24,24 +26,38 @@ export function AuthProvider({ children }: AuthProviderProps) { const [userCache, setUserCache] = useCache("user"); const [currentUser, setCurrentUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [isBanned, setIsBanned] = useState(false); useOnce(async () => { console.log("(AuthContext.tsx) authenticating..."); - const req = await trpc.user.myself.query({}); - console.log("(AuthContext.tsx) response:", req); + try { + const req = await trpc.user.myself.query({}); - if (!req.ok || req.data.user === null) { - console.log("(AuthContext.tsx) user is not authenticated"); - setUserCache(null); + console.log("(AuthContext.tsx) response:", req); + + if (!req.ok || req.data.user === null) { + console.log("(AuthContext.tsx) user is not authenticated"); + setUserCache(null); + setIsLoading(false); + return; + } + + console.log("(AuthContext.tsx) user is authenticated"); + setUserCache(req.data.user); + setCurrentUser(req.data.user); setIsLoading(false); - return; } - - console.log("(AuthContext.tsx) user is authenticated"); - setUserCache(req.data.user); - setCurrentUser(req.data.user); - setIsLoading(false); + catch (err) { + if (handleBanError(err)) { + console.log("(AuthContext.tsx) user is banned"); + setIsBanned(true); + setUserCache(null); + setIsLoading(false); + return; + } + throw err; + } }); const signOut = useCallback(async () => { @@ -58,6 +74,8 @@ export function AuthProvider({ children }: AuthProviderProps) { const value: AuthContextValue = { currentUser: effectiveUser, isLoading, + isBanned, + banReason: effectiveUser?.private.isBanned ? effectiveUser.private.bannedReason : null, signOut }; diff --git a/apps/web/src/client/trpc.ts b/apps/web/src/client/trpc.ts index d819ffad..9374ce7a 100644 --- a/apps/web/src/client/trpc.ts +++ b/apps/web/src/client/trpc.ts @@ -1,4 +1,4 @@ -import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; +import { createTRPCProxyClient, httpBatchLink, TRPCClientError } from "@trpc/client"; import { CreateReactUtils } from "@trpc/react-query/shared"; import { NextPageContext } from "next"; @@ -6,6 +6,22 @@ import type { AppRouter } from "@/server/routers/_app"; export type Api = CreateReactUtils; +let banRedirectTriggered = false; + +export function handleBanError(error: unknown): boolean { + if ( + error instanceof TRPCClientError && + error.message === "Your account has been banned." + ) { + if (!banRedirectTriggered && typeof window !== "undefined") { + banRedirectTriggered = true; + window.location.href = "/banned"; + } + return true; + } + return false; +} + function getBaseUrl() { if (typeof window !== "undefined") return ""; // browser should use relative path diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index bd7c5d0c..1de332c8 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -1,15 +1,41 @@ import type { AppType } from "next/app"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; import "@/client/styles/globals.css"; -import { AuthProvider } from "@/client/context/AuthContext"; +import { AuthProvider, useAuthContext } from "@/client/context/AuthContext"; import { initLogBucket } from "@/client/logBucket"; +import { handleBanError } from "@/client/trpc"; initLogBucket(); +if (typeof window !== "undefined") { + window.addEventListener("unhandledrejection", (event) => { + if (handleBanError(event.reason)) { + event.preventDefault(); + } + }); +} + +function BanRedirect({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const { isBanned, isLoading } = useAuthContext(); + + useEffect(() => { + if (!isLoading && isBanned && router.pathname !== "/banned") { + router.replace("/banned"); + } + }, [isBanned, isLoading, router]); + + return <>{children}; +} + const App: AppType = ({ Component, pageProps }) => { return ( - + + + ); }; diff --git a/apps/web/src/pages/admin/index.tsx b/apps/web/src/pages/admin/index.tsx new file mode 100644 index 00000000..680dff92 --- /dev/null +++ b/apps/web/src/pages/admin/index.tsx @@ -0,0 +1,541 @@ +import { useState } from "react"; +import Icon from "@hackclub/icons"; +import NextLink from "next/link"; + +import type { User, BanRecord } from "@/server/routers/api/user"; +import { TimeAgo } from "@/client/components/TimeAgo"; + +import { trpc } from "@/client/trpc"; +import { useAuth } from "@/client/hooks/useAuth"; +import { useAsyncEffect } from "@/client/hooks/useAsyncEffect"; + +import RootLayout from "@/client/components/RootLayout"; +import { ProfilePicture } from "@/client/components/ProfilePicture"; + +import { Button } from "@/client/components/ui/Button"; +import { Skeleton } from "@/client/components/ui/Skeleton"; +import { TextInput } from "@/client/components/ui/TextInput"; +import { ErrorModal } from "@/client/components/ui/ErrorModal"; +import { WindowedModal } from "@/client/components/ui/WindowedModal"; +import { TextareaInput } from "@/client/components/ui/TextareaInput"; + +export default function AdminPage() { + const { currentUser, isLoading: authLoading } = useAuth(true); + + const [users, setUsers] = useState(null); + const [nextCursor, setNextCursor] = useState(undefined); + const [error, setError] = useState(null); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const [showBannedOnly, setShowBannedOnly] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResult, setSearchResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + + const [banModalOpen, setBanModalOpen] = useState(false); + const [banTargetUser, setBanTargetUser] = useState(null); + const [banReason, setBanReason] = useState(""); + const [banReasonInternal, setBanReasonInternal] = useState(""); + const [isBanning, setIsBanning] = useState(false); + + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [deleteTargetUser, setDeleteTargetUser] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const [historyModalOpen, setHistoryModalOpen] = useState(false); + const [historyTargetUser, setHistoryTargetUser] = useState(null); + const [banHistory, setBanHistory] = useState(null); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + const isAdmin = currentUser && (currentUser.private.permissionLevel === "ADMIN" || currentUser.private.permissionLevel === "ROOT"); + + async function loadUsers(cursor?: string, onlyBanned?: boolean) { + try { + const res = await trpc.user.list.query({ + limit: 20, + cursor, + onlyBanned + }); + + if (!res.ok) { + setError(res.error); + return; + } + + if (cursor) { + setUsers(prev => [...(prev ?? []), ...res.data.users]); + } + else { + setUsers(res.data.users); + } + + setNextCursor(res.data.nextCursor); + } + catch (err) { + console.error("(admin/index.tsx) Error loading users:", err); + setError("Failed to load users"); + } + } + + useAsyncEffect(async () => { + if (!currentUser || !isAdmin) + return; + + await loadUsers(undefined, showBannedOnly); + }, [currentUser, showBannedOnly]); + + async function handleSearch() { + if (!searchQuery.trim()) + return; + + setIsSearching(true); + setSearchResult(null); + + try { + const res = await trpc.user.query.query( + searchQuery.startsWith("@") + ? { handle: searchQuery.substring(1).trim() } + : { id: searchQuery.trim() } + ); + + if (!res.ok) { + setError(res.error); + return; + } + + if (res.data.user && "private" in res.data.user) { + setSearchResult(res.data.user as User); + } + else { + setError("User not found or you don't have permission to view them."); + } + } + catch (err) { + console.error("(admin/index.tsx) Error searching user:", err); + setError("Failed to search for user"); + } + finally { + setIsSearching(false); + } + } + + async function handleLoadMore() { + if (!nextCursor || isLoadingMore) + return; + + setIsLoadingMore(true); + await loadUsers(nextCursor, showBannedOnly); + setIsLoadingMore(false); + } + + function openBanModal(user: User) { + setBanTargetUser(user); + setBanReason(""); + setBanReasonInternal(""); + setBanModalOpen(true); + } + + async function handleBanUser(ban: boolean) { + if (!banTargetUser) + return; + + setIsBanning(true); + + try { + const res = await trpc.user.setBanStatus.mutate({ + id: banTargetUser.id, + isBanned: ban, + reason: ban ? banReason : undefined, + reasonInternal: ban ? banReasonInternal : undefined + }); + + if (!res.ok) { + setError(res.error); + return; + } + + setUsers(prev => prev?.map(u => + u.id === banTargetUser.id ? res.data.user : u + ) ?? null); + + if (searchResult?.id === banTargetUser.id) { + setSearchResult(res.data.user); + } + + setBanModalOpen(false); + } + catch (err) { + console.error("(admin/index.tsx) Error banning user:", err); + setError("Failed to update ban status"); + } + finally { + setIsBanning(false); + } + } + + function openDeleteModal(user: User) { + setDeleteTargetUser(user); + setDeleteModalOpen(true); + } + + async function openHistoryModal(user: User) { + setHistoryTargetUser(user); + setBanHistory(null); + setHistoryModalOpen(true); + setIsLoadingHistory(true); + + try { + const res = await trpc.user.getBanHistory.query({ id: user.id }); + + if (!res.ok) { + setError(res.error); + setHistoryModalOpen(false); + return; + } + + setBanHistory(res.data.records); + } + catch (err) { + console.error("(admin/index.tsx) Error loading ban history:", err); + setError("Failed to load ban history"); + setHistoryModalOpen(false); + } + finally { + setIsLoadingHistory(false); + } + } + + async function handleDeleteUser() { + if (!deleteTargetUser) + return; + + setIsDeleting(true); + + try { + const res = await trpc.user.deleteUser.mutate({ + id: deleteTargetUser.id + }); + + if (!res.ok) { + setError(res.error); + return; + } + + setUsers(prev => prev?.filter(u => u.id !== deleteTargetUser.id) ?? null); + + if (searchResult?.id === deleteTargetUser.id) { + setSearchResult(null); + } + + setDeleteModalOpen(false); + } + catch (err) { + console.error("(admin/index.tsx) Error deleting user:", err); + setError("Failed to delete user"); + } + finally { + setIsDeleting(false); + } + } + + if (authLoading) { + return ( + +
+ + +
+
+ ); + } + + if (!isAdmin) { + return ( + +
+ +

Access Denied

+

You don't have permission to access the admin dashboard.

+
+
+ ); + } + + function UserRow({ user }: { user: User }) { + const isBanned = user.private.isBanned; + const isRoot = user.private.permissionLevel === "ROOT"; + const canModerate = currentUser?.private.permissionLevel === "ROOT" || user.private.permissionLevel === "USER"; + + return ( +
+ +
+
+ + {user.displayName} + + @{user.handle} + {user.private.permissionLevel !== "USER" && ( + + {user.private.permissionLevel} + + )} + {isBanned && ( + + BANNED + + )} +
+
+ {user.id} +
+ {isBanned && (user.private.bannedReason || user.private.bannedReasonInternal) && ( +
+ {user.private.bannedReason && ( +
Public: {user.private.bannedReason}
+ )} + {user.private.bannedReasonInternal && ( +
Internal: {user.private.bannedReasonInternal}
+ )} +
+ )} +
+
+ + + + + {canModerate && !isRoot && ( + <> + + {currentUser?.private.permissionLevel === "ROOT" && ( + + )} + + )} +
+
+ ); + } + + return ( + +
+

Admin Dashboard

+ +
+

Search User

+
+ + +
+ + {searchResult && ( +
+ +
+ )} +
+ +
+
+

All Users

+ +
+ + {!users ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : users.length === 0 ? ( +
+ {showBannedOnly ? "No banned users found." : "No users found."} +
+ ) : ( +
+ {users.map(user => ( + + ))} +
+ )} + + {nextCursor && ( +
+ +
+ )} +
+
+ + +
+

+ {banTargetUser?.private.isBanned + ? `Are you sure you want to unban ${banTargetUser?.displayName}?` + : `Are you sure you want to ban ${banTargetUser?.displayName}?`} +

+ {!banTargetUser?.private.isBanned && ( + <> + + + + )} +
+ + +
+
+
+ + +
+

+ Are you sure you want to permanently delete {deleteTargetUser?.displayName}? + This will remove all their timelapses, comments, and data. This action cannot be undone. +

+
+ + +
+
+
+ + +
+ {isLoadingHistory ? ( +
+ + +
+ ) : banHistory && banHistory.length === 0 ? ( +

No ban history for this user.

+ ) : ( + banHistory?.map(record => ( +
+
+ + {record.action === "BAN" ? "Banned" : "Unbanned"} + + + + +
+
+ By: {record.performedBy.displayName} (@{record.performedBy.handle}) +
+ {record.reason && ( +
+ Public reason: {record.reason} +
+ )} + {record.reasonInternal && ( +
+ Internal reason: {record.reasonInternal} +
+ )} +
+ )) + )} +
+
+ +
+
+ + {error && ( + !isOpen && setError(null)} + message={error} + /> + )} +
+ ); +} diff --git a/apps/web/src/pages/banned.tsx b/apps/web/src/pages/banned.tsx new file mode 100644 index 00000000..7369ca1e --- /dev/null +++ b/apps/web/src/pages/banned.tsx @@ -0,0 +1,35 @@ +import Icon from "@hackclub/icons"; + +import RootLayout from "@/client/components/RootLayout"; +import { Button } from "@/client/components/ui/Button"; +import { useAuthContext } from "@/client/context/AuthContext"; + +export default function BannedPage() { + const { signOut, banReason } = useAuthContext(); + + return ( + +
+
+ +

Account Banned

+

+ Your account has been banned from Lapse. +

+ {banReason && ( +
+

Reason:

+

{banReason}

+
+ )} +

+ If you believe this is a mistake, please contact an administrator. +

+ +
+
+
+ ); +} diff --git a/apps/web/src/server/routers/api/comment.ts b/apps/web/src/server/routers/api/comment.ts index be2a13f8..4c22e1de 100644 --- a/apps/web/src/server/routers/api/comment.ts +++ b/apps/web/src/server/routers/api/comment.ts @@ -2,7 +2,7 @@ import "@/server/allow-only-server"; import { z } from "zod"; -import { apiResult, apiErr, apiOk, Err } from "@/shared/common"; +import { apiResult, apiErr, apiOk, Err, oneOf } from "@/shared/common"; import { router, protectedProcedure } from "@/server/trpc"; import { dtoPublicUser, PublicUserSchema } from "@/server/routers/api/user"; @@ -115,14 +115,19 @@ export default router({ logRequest("comment/delete", req); const comment = await database.comment.findUnique({ - where: { id: req.input.commentId } + where: { id: req.input.commentId }, + include: { timelapse: true } }); if (!comment) return apiErr("NOT_FOUND", "Comment not found."); - if (comment.authorId !== req.ctx.user.id) - return apiErr("NO_PERMISSION", "You can only delete your own comments."); + const isAuthor = comment.authorId === req.ctx.user.id; + const isAdmin = req.ctx.user.permissionLevel in oneOf("ADMIN", "ROOT"); + const isTimelapseOwner = comment.timelapse.ownerId === req.ctx.user.id; + + if (!isAuthor && !isAdmin && !isTimelapseOwner) + return apiErr("NO_PERMISSION", "You don't have permission to delete this comment."); await database.comment.delete({ where: { id: req.input.commentId } diff --git a/apps/web/src/server/routers/api/user.ts b/apps/web/src/server/routers/api/user.ts index f22c1571..6215a39a 100644 --- a/apps/web/src/server/routers/api/user.ts +++ b/apps/web/src/server/routers/api/user.ts @@ -2,7 +2,7 @@ import "@/server/allow-only-server"; import { z } from "zod"; -import { apiResult, assert, descending, apiErr, when, apiOk } from "@/shared/common"; +import { apiResult, assert, descending, apiErr, when, apiOk, oneOf } from "@/shared/common"; import { procedure, router, protectedProcedure } from "@/server/trpc"; import { logError, logRequest } from "@/server/serverCommon"; @@ -38,7 +38,7 @@ export const KnownDeviceSchema = z.object({ name: z.string() }); -export const UserHandle = z.string().min(3).max(16); +export const UserHandle = z.string().min(3).max(32); export const UserDisplayName = z.string().min(1).max(24); export const UserBio = z.string().max(160).default(""); export const UserUrlList = z.array(z.url().max(64).min(1)).max(4); @@ -56,7 +56,27 @@ export const PrivateUserDataSchema = z.object({ * Whether the user needs to re-authenticate. This is `true` when, for example, the user has authenticated * with Slack before, but has not yet logged in with Hackatime. */ - needsReauth: z.boolean() + needsReauth: z.boolean(), + + /** + * Whether the user's account has been banned. + */ + isBanned: z.boolean(), + + /** + * The date when the user was banned, or `null` if not banned. + */ + bannedAt: ApiDate.nullable(), + + /** + * The public reason provided by an administrator for the ban (shown to user). + */ + bannedReason: z.string(), + + /** + * The internal reason for the ban (only visible to admins). + */ + bannedReasonInternal: z.string() }); /** @@ -127,6 +147,52 @@ export const UserSchema = PublicUserSchema.safeExtend({ */ export type DbCompositeUser = db.User & { devices: db.KnownDevice[] }; +/** + * Represents a ban action type. + */ +export type BanAction = z.infer; +export const BanActionSchema = z.enum(["BAN", "UNBAN"]); + +/** + * Represents a record of a ban or unban action. + */ +export type BanRecord = z.infer; +export const BanRecordSchema = z.object({ + id: PublicId, + createdAt: ApiDate, + action: BanActionSchema, + reason: z.string(), + reasonInternal: z.string(), + performedBy: z.object({ + id: PublicId, + handle: UserHandle, + displayName: UserDisplayName + }) +}); + +/** + * Represents a `db.BanRecord` with related tables included. + */ +export type DbBanRecord = db.BanRecord & { performedBy: db.User }; + +/** + * Converts a database representation of a ban record to a runtime (API) one. + */ +export function dtoBanRecord(entity: DbBanRecord): BanRecord { + return { + id: entity.id, + createdAt: entity.createdAt.getTime(), + action: entity.action, + reason: entity.reason, + reasonInternal: entity.reasonInternal, + performedBy: { + id: entity.performedBy.id, + handle: entity.performedBy.handle, + displayName: entity.performedBy.displayName + } + }; +} + /** * Converts a database representation of a known device to a runtime (API) one. */ @@ -163,7 +229,11 @@ export function dtoUser(entity: DbCompositeUser): User { private: { permissionLevel: entity.permissionLevel, devices: entity.devices.map(dtoKnownDevice), - needsReauth: entity.slackId !== null && entity.hackatimeId === null + needsReauth: entity.slackId !== null && entity.hackatimeId === null, + isBanned: entity.isBanned, + bannedAt: entity.bannedAt ? entity.bannedAt.getTime() : null, + bannedReason: entity.bannedReason, + bannedReasonInternal: entity.bannedReasonInternal } }; } @@ -242,8 +312,10 @@ export default router({ if (!dbUser) return apiOk({ user: null }); - // Watch out! Make sure we never return a `User` to an unauthorized user here. - const user: User | PublicUser = req.ctx.user?.id == dbUser.id + // Return full User data if the caller is the user themselves or an admin. + const isSelf = req.ctx.user?.id === dbUser.id; + const isAdmin = req.ctx.user && req.ctx.user.permissionLevel in oneOf("ADMIN", "ROOT"); + const user: User | PublicUser = isSelf || isAdmin ? dtoUser(dbUser) : dtoPublicUser(dbUser); @@ -514,6 +586,231 @@ export default router({ where: { id: req.ctx.user.id } }); + return apiOk({}); + }), + + /** + * Sets the ban status of a user. Only administrators can use this endpoint. + */ + setBanStatus: protectedProcedure() + .input(z.object({ + /** + * The ID of the user to ban or unban. + */ + id: PublicId, + + /** + * Whether the user should be banned. + */ + isBanned: z.boolean(), + + /** + * The public reason for the ban (shown to the user). Only used when `isBanned` is `true`. + */ + reason: z.string().max(512).optional(), + + /** + * The internal reason for the ban (only visible to admins). Only used when `isBanned` is `true`. + */ + reasonInternal: z.string().max(512).optional() + })) + .output(apiResult({ + user: UserSchema + })) + .mutation(async (req) => { + logRequest("user/setBanStatus", req); + + const actor = req.ctx.user; + + if (!(actor.permissionLevel in oneOf("ADMIN", "ROOT"))) + return apiErr("NO_PERMISSION", "Only administrators can change ban status."); + + const target = await database.user.findFirst({ + where: { id: req.input.id }, + include: { devices: true } + }); + + if (!target) + return apiErr("NOT_FOUND", "User not found."); + + if (target.permissionLevel !== "USER" && actor.permissionLevel !== "ROOT") + return apiErr("NO_PERMISSION", "Only ROOT can change ban status of other administrators."); + + if (target.id === actor.id) + return apiErr("NO_PERMISSION", "You cannot change your own ban status."); + + const now = new Date(); + const reason = req.input.reason ?? ""; + const reasonInternal = req.input.reasonInternal ?? ""; + + const updateData: Partial = { + isBanned: req.input.isBanned, + bannedReason: req.input.isBanned ? reason : "", + bannedReasonInternal: req.input.isBanned ? reasonInternal : "", + bannedAt: req.input.isBanned ? now : null + }; + + const [updatedUser] = await database.$transaction([ + database.user.update({ + where: { id: target.id }, + data: updateData, + include: { devices: true } + }), + database.banRecord.create({ + data: { + action: req.input.isBanned ? "BAN" : "UNBAN", + reason: reason, + reasonInternal: reasonInternal, + targetId: target.id, + performedById: actor.id + } + }) + ]); + + return apiOk({ user: dtoUser(updatedUser) }); + }), + + /** + * Gets the ban history for a user. Only administrators can use this endpoint. + */ + getBanHistory: protectedProcedure() + .input(z.object({ + /** + * The ID of the user to get ban history for. + */ + id: PublicId + })) + .output(apiResult({ + records: z.array(BanRecordSchema) + })) + .query(async (req) => { + logRequest("user/getBanHistory", req); + + const actor = req.ctx.user; + if (!(actor.permissionLevel in oneOf("ADMIN", "ROOT"))) + return apiErr("NO_PERMISSION", "Only administrators can view ban history."); + + const records = await database.banRecord.findMany({ + where: { targetId: req.input.id }, + include: { performedBy: true }, + orderBy: { createdAt: "desc" } + }); + + return apiOk({ records: records.map(dtoBanRecord) }); + }), + + /** + * Lists all users. Only administrators can use this endpoint. + */ + list: protectedProcedure() + .input(z.object({ + /** + * Maximum number of users to return. + */ + limit: z.number().int().min(1).max(100).default(50), + + /** + * Cursor for pagination. + */ + cursor: PublicId.optional(), + + /** + * If `true`, only returns banned users. + */ + onlyBanned: z.boolean().optional() + })) + .output(apiResult({ + users: z.array(UserSchema), + nextCursor: PublicId.optional() + })) + .query(async (req) => { + logRequest("user/list", req); + + const actor = req.ctx.user; + if (!(actor.permissionLevel in oneOf("ADMIN", "ROOT"))) + return apiErr("NO_PERMISSION", "Only administrators can list users."); + + const where: db.Prisma.UserWhereInput = { + ...(req.input.onlyBanned ? { isBanned: true } : {}) + }; + + const users = await database.user.findMany({ + where, + include: { devices: true }, + take: req.input.limit + 1, + orderBy: { createdAt: "desc" }, + ...(req.input.cursor ? { cursor: { id: req.input.cursor }, skip: 1 } : {}) + }); + + const hasMore = users.length > req.input.limit; + const items = hasMore ? users.slice(0, -1) : users; + + return apiOk({ + users: items.map(dtoUser), + nextCursor: hasMore ? items[items.length - 1].id : undefined + }); + }), + + /** + * Deletes a user and all their associated data. Only ROOT can use this endpoint. + */ + deleteUser: protectedProcedure() + .input(z.object({ + /** + * The ID of the user to delete. + */ + id: PublicId + })) + .output(apiResult({})) + .mutation(async (req) => { + logRequest("user/deleteUser", req); + + const actor = req.ctx.user; + + if (actor.permissionLevel !== "ROOT") + return apiErr("NO_PERMISSION", "Only ROOT can delete users."); + + const target = await database.user.findFirst({ + where: { id: req.input.id } + }); + + if (!target) + return apiErr("NOT_FOUND", "User not found."); + + if (target.id === actor.id) + return apiErr("NO_PERMISSION", "You cannot delete your own account."); + + if (target.permissionLevel === "ROOT") + return apiErr("NO_PERMISSION", "Cannot delete ROOT users."); + + const timelapses = await database.timelapse.findMany({ + where: { ownerId: target.id } + }); + + for (const timelapse of timelapses) { + await deleteTimelapse(timelapse.id, "SERVER"); + } + + await database.comment.deleteMany({ + where: { authorId: target.id } + }); + + await database.knownDevice.deleteMany({ + where: { ownerId: target.id } + }); + + await database.uploadToken.deleteMany({ + where: { ownerId: target.id } + }); + + await database.draftTimelapse.deleteMany({ + where: { ownerId: target.id } + }); + + await database.user.delete({ + where: { id: target.id } + }); + return apiOk({}); }) }); diff --git a/apps/web/src/server/trpc.ts b/apps/web/src/server/trpc.ts index 2bc40927..a6d48539 100644 --- a/apps/web/src/server/trpc.ts +++ b/apps/web/src/server/trpc.ts @@ -53,6 +53,13 @@ export function protectedProcedure() { message: "Authentication required", }); } + + if (ctx.user.isBanned) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Your account has been banned.", + }); + } return opts.next({ ctx: {...ctx, user: ctx.user }, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c32fe6d1..c5b4efa6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -37,6 +37,7 @@ "src/types/**/*.d.ts" ], "exclude": [ - "node_modules" + "node_modules", + "vitest.config.ts" ] } diff --git a/package.json b/package.json index f8e1c7b0..a4b8e058 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "packageManager": "pnpm@10.25.0", + "packageManager": "pnpm@10.27.0", "devDependencies": { "turbo": "^2.6.3" } From bafffb07f95a9c2ac1414bf0757e0d877e5a7fa3 Mon Sep 17 00:00:00 2001 From: DragonSenseiGuy <200907890+DragonSenseiGuy@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:34:36 -0500 Subject: [PATCH 02/19] fix UI/UX issues --- apps/web/src/client/components/ui/Switch.tsx | 37 +++++ apps/web/src/pages/admin/index.tsx | 151 +++++++++++++------ apps/web/src/pages/banned.tsx | 2 +- apps/web/src/server/routers/api/user.ts | 39 +++++ 4 files changed, 186 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/client/components/ui/Switch.tsx diff --git a/apps/web/src/client/components/ui/Switch.tsx b/apps/web/src/client/components/ui/Switch.tsx new file mode 100644 index 00000000..d8ca739b --- /dev/null +++ b/apps/web/src/client/components/ui/Switch.tsx @@ -0,0 +1,37 @@ +export function Switch({ + checked, + onChange, + disabled, + label +}: { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + label?: string; +}) { + function handleClick() { + if (!disabled) { + onChange(!checked); + } + } + + return ( +