From 64f75a0fb010d4a12086b839cc9d80ed011aa2b3 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Sat, 2 Mar 2024 12:48:26 +0000 Subject: feature: Show user list in admin page --- packages/web/app/dashboard/admin/page.tsx | 193 ++++++++++++++++++-------- packages/web/server/api/routers/admin.ts | 11 +- packages/web/server/api/routers/users.test.ts | 46 +++++- packages/web/server/api/routers/users.ts | 43 +++++- packages/web/server/api/trpc.ts | 8 ++ 5 files changed, 228 insertions(+), 73 deletions(-) (limited to 'packages') diff --git a/packages/web/app/dashboard/admin/page.tsx b/packages/web/app/dashboard/admin/page.tsx index b2f8e9ac..6babdd79 100644 --- a/packages/web/app/dashboard/admin/page.tsx +++ b/packages/web/app/dashboard/admin/page.tsx @@ -13,27 +13,11 @@ import { import { toast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; import { keepPreviousData } from "@tanstack/react-query"; +import { Trash } from "lucide-react"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; -export default function AdminPage() { - const router = useRouter(); - const { data: session, status } = useSession(); - - if (status == "loading") { - return ; - } - - if (!session || session.user.role != "admin") { - router.push("/"); - return; - } - - const { data } = api.admin.stats.useQuery(undefined, { - refetchInterval: 1000, - placeholderData: keepPreviousData, - }); - +function ActionsSection() { const { mutate: recrawlLinks, isPending: isRecrawlPending } = api.admin.recrawlAllLinks.useMutation({ onSuccess: () => { @@ -65,47 +49,7 @@ export default function AdminPage() { }); return ( -
-

Admin

-
- {data ? ( - <> -

Server Stats

- - - - Num Users - {data.numUsers} - - - Num Bookmarks - {data.numBookmarks} - - -
-
-

Background Jobs

- - - - Pending Crawling Jobs - {data.pendingCrawls} - - - Pending Indexing Jobs - {data.pendingIndexing} - - - Pending OpenAI Jobs - {data.pendingOpenai} - - -
- - ) : ( - - )} -
+ <>

Actions

Reindex All Bookmarks + + ); +} + +function ServerStatsSection() { + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + if (!serverStats) { + return ; + } + + return ( + <> +

Server Stats

+ + + + Num Users + {serverStats.numUsers} + + + Num Bookmarks + {serverStats.numBookmarks} + + +
+
+

Background Jobs

+ + + + Pending Crawling Jobs + {serverStats.pendingCrawls} + + + Pending Indexing Jobs + {serverStats.pendingIndexing} + + + Pending OpenAI Jobs + {serverStats.pendingOpenai} + + +
+ + ); +} + +function UsersSection() { + const { data: session } = useSession(); + const invalidateUserList = api.useUtils().users.list.invalidate; + const { data: users } = api.users.list.useQuery(); + const { mutate: deleteUser, isPending: isDeletionPending } = + api.users.delete.useMutation({ + onSuccess: () => { + toast({ + description: "User deleted", + }); + invalidateUserList(); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: `Something went wrong: ${e.message}`, + }); + }, + }); + + if (!users) { + return ; + } + + return ( + <> +

Users

+ + + Name + Email + Role + Action + + + {users.users.map((u) => ( + + {u.name} + {u.email} + {u.role} + + deleteUser({ userId: u.id })} + loading={isDeletionPending} + disabled={session!.user.id == u.id} + > + + + + + ))} + +
+ + ); +} + +export default function AdminPage() { + const router = useRouter(); + const { data: session, status } = useSession(); + + if (status == "loading") { + return ; + } + + if (!session || session.user.role != "admin") { + router.push("/"); + return; + } + + return ( +
+

Admin

+
+ +
+ +
+
); } diff --git a/packages/web/server/api/routers/admin.ts b/packages/web/server/api/routers/admin.ts index 92769151..c3f6235a 100644 --- a/packages/web/server/api/routers/admin.ts +++ b/packages/web/server/api/routers/admin.ts @@ -1,6 +1,5 @@ -import { authedProcedure, router } from "../trpc"; +import { adminProcedure, router } from "../trpc"; import { z } from "zod"; -import { TRPCError } from "@trpc/server"; import { count } from "drizzle-orm"; import { bookmarks, users } from "@hoarder/db/schema"; import { @@ -9,14 +8,6 @@ import { SearchIndexingQueue, } from "@hoarder/shared/queues"; -const adminProcedure = authedProcedure.use(function isAdmin(opts) { - const user = opts.ctx.user; - if (user.role != "admin") { - throw new TRPCError({ code: "FORBIDDEN" }); - } - return opts.next(opts); -}); - export const adminAppRouter = router({ stats: adminProcedure .output( diff --git a/packages/web/server/api/routers/users.test.ts b/packages/web/server/api/routers/users.test.ts index b188d3a0..1ee04f99 100644 --- a/packages/web/server/api/routers/users.test.ts +++ b/packages/web/server/api/routers/users.test.ts @@ -1,5 +1,9 @@ -import { CustomTestContext, defaultBeforeEach } from "@/lib/testUtils"; -import { expect, describe, test, beforeEach } from "vitest"; +import { + CustomTestContext, + defaultBeforeEach, + getApiCaller, +} from "@/lib/testUtils"; +import { expect, describe, test, beforeEach, assert } from "vitest"; beforeEach(defaultBeforeEach(false)); @@ -54,4 +58,42 @@ describe("User Routes", () => { }), ).rejects.toThrow(/Email is already taken/); }); + + test("privacy checks", async ({ + db, + unauthedAPICaller, + }) => { + const adminUser = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test123@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const [user1, user2] = await Promise.all( + ["test1234@test.com", "test12345@test.com"].map((e) => + unauthedAPICaller.users.create({ + name: "Test User", + email: e, + password: "pass1234", + confirmPassword: "pass1234", + }), + ), + ); + + assert(adminUser.role == "admin"); + assert(user1.role == "user"); + assert(user2.role == "user"); + + const user2Caller = getApiCaller(db, user2.id); + + // A normal user can't delete other users + await expect(() => + user2Caller.users.delete({ + userId: user1.id, + }), + ).rejects.toThrow(/FORBIDDEN/); + + // A normal user can't list all users + await expect(() => user2Caller.users.list()).rejects.toThrow(/FORBIDDEN/); + }); }); diff --git a/packages/web/server/api/routers/users.ts b/packages/web/server/api/routers/users.ts index 3d5d982d..32d10860 100644 --- a/packages/web/server/api/routers/users.ts +++ b/packages/web/server/api/routers/users.ts @@ -1,23 +1,25 @@ import { zSignUpSchema } from "@/lib/types/api/users"; -import { publicProcedure, router } from "../trpc"; +import { adminProcedure, publicProcedure, router } from "../trpc"; import { SqliteError } from "@hoarder/db"; import { z } from "zod"; import { hashPassword } from "@/server/auth"; import { TRPCError } from "@trpc/server"; import { users } from "@hoarder/db/schema"; -import { count } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; export const usersAppRouter = router({ create: publicProcedure .input(zSignUpSchema) .output( z.object({ + id: z.string(), name: z.string(), email: z.string(), role: z.enum(["user", "admin"]).nullable(), }), ) .mutation(async ({ input, ctx }) => { + // TODO: This is racy, but that's probably fine. const [{ count: userCount }] = await ctx.db .select({ count: count() }) .from(users); @@ -31,6 +33,7 @@ export const usersAppRouter = router({ role: userCount == 0 ? "admin" : "user", }) .returning({ + id: users.id, name: users.name, email: users.email, role: users.role, @@ -51,4 +54,40 @@ export const usersAppRouter = router({ }); } }), + list: adminProcedure + .output( + z.object({ + users: z.array( + z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + role: z.enum(["user", "admin"]).nullable(), + }), + ), + }), + ) + .query(async ({ ctx }) => { + const users = await ctx.db.query.users.findMany({ + columns: { + id: true, + name: true, + email: true, + role: true, + }, + }); + return { users }; + }), + delete: adminProcedure + .input( + z.object({ + userId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const res = await ctx.db.delete(users).where(eq(users.id, input.userId)); + if (res.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + }), }); diff --git a/packages/web/server/api/trpc.ts b/packages/web/server/api/trpc.ts index 93fc961a..0ba09e94 100644 --- a/packages/web/server/api/trpc.ts +++ b/packages/web/server/api/trpc.ts @@ -43,3 +43,11 @@ export const authedProcedure = procedure.use(function isAuthed(opts) { }, }); }); + +export const adminProcedure = authedProcedure.use(function isAdmin(opts) { + const user = opts.ctx.user; + if (user.role != "admin") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return opts.next(opts); +}); -- cgit v1.2.3-70-g09d2