diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/web/app/dashboard/admin/page.tsx | 193 | ||||
| -rw-r--r-- | packages/web/server/api/routers/admin.ts | 11 | ||||
| -rw-r--r-- | packages/web/server/api/routers/users.test.ts | 46 | ||||
| -rw-r--r-- | packages/web/server/api/routers/users.ts | 43 | ||||
| -rw-r--r-- | packages/web/server/api/trpc.ts | 8 |
5 files changed, 228 insertions, 73 deletions
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 <LoadingSpinner />; - } - - 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 ( - <div className="m-4 flex flex-col gap-5 rounded-md border bg-white p-4"> - <p className="text-2xl">Admin</p> - <hr /> - {data ? ( - <> - <p className="text-xl">Server Stats</p> - <Table className="w-1/2"> - <TableBody> - <TableRow> - <TableCell className="w-2/3">Num Users</TableCell> - <TableCell>{data.numUsers}</TableCell> - </TableRow> - <TableRow> - <TableCell>Num Bookmarks</TableCell> - <TableCell>{data.numBookmarks}</TableCell> - </TableRow> - </TableBody> - </Table> - <hr /> - <p className="text-xl">Background Jobs</p> - <Table className="w-1/2"> - <TableBody> - <TableRow> - <TableCell className="w-2/3">Pending Crawling Jobs</TableCell> - <TableCell>{data.pendingCrawls}</TableCell> - </TableRow> - <TableRow> - <TableCell>Pending Indexing Jobs</TableCell> - <TableCell>{data.pendingIndexing}</TableCell> - </TableRow> - <TableRow> - <TableCell>Pending OpenAI Jobs</TableCell> - <TableCell>{data.pendingOpenai}</TableCell> - </TableRow> - </TableBody> - </Table> - </> - ) : ( - <LoadingSpinner /> - )} - <hr /> + <> <p className="text-xl">Actions</p> <ActionButton className="w-1/2" @@ -123,6 +67,137 @@ export default function AdminPage() { > Reindex All Bookmarks </ActionButton> + </> + ); +} + +function ServerStatsSection() { + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + if (!serverStats) { + return <LoadingSpinner />; + } + + return ( + <> + <p className="text-xl">Server Stats</p> + <Table className="w-1/2"> + <TableBody> + <TableRow> + <TableCell className="w-2/3">Num Users</TableCell> + <TableCell>{serverStats.numUsers}</TableCell> + </TableRow> + <TableRow> + <TableCell>Num Bookmarks</TableCell> + <TableCell>{serverStats.numBookmarks}</TableCell> + </TableRow> + </TableBody> + </Table> + <hr /> + <p className="text-xl">Background Jobs</p> + <Table className="w-1/2"> + <TableBody> + <TableRow> + <TableCell className="w-2/3">Pending Crawling Jobs</TableCell> + <TableCell>{serverStats.pendingCrawls}</TableCell> + </TableRow> + <TableRow> + <TableCell>Pending Indexing Jobs</TableCell> + <TableCell>{serverStats.pendingIndexing}</TableCell> + </TableRow> + <TableRow> + <TableCell>Pending OpenAI Jobs</TableCell> + <TableCell>{serverStats.pendingOpenai}</TableCell> + </TableRow> + </TableBody> + </Table> + </> + ); +} + +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 <LoadingSpinner />; + } + + return ( + <> + <p className="text-xl">Users</p> + <Table> + <TableHeader> + <TableHead>Name</TableHead> + <TableHead>Email</TableHead> + <TableHead>Role</TableHead> + <TableHead>Action</TableHead> + </TableHeader> + <TableBody> + {users.users.map((u) => ( + <TableRow key={u.id}> + <TableCell>{u.name}</TableCell> + <TableCell>{u.email}</TableCell> + <TableCell>{u.role}</TableCell> + <TableCell> + <ActionButton + variant="destructive" + onClick={() => deleteUser({ userId: u.id })} + loading={isDeletionPending} + disabled={session!.user.id == u.id} + > + <Trash /> + </ActionButton> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </> + ); +} + +export default function AdminPage() { + const router = useRouter(); + const { data: session, status } = useSession(); + + if (status == "loading") { + return <LoadingSpinner />; + } + + if (!session || session.user.role != "admin") { + router.push("/"); + return; + } + + return ( + <div className="m-4 flex flex-col gap-5 rounded-md border bg-white p-4"> + <p className="text-2xl">Admin</p> + <hr /> + <ServerStatsSection /> + <hr /> + <UsersSection /> + <hr /> + <ActionsSection /> </div> ); } 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<CustomTestContext>(defaultBeforeEach(false)); @@ -54,4 +58,42 @@ describe("User Routes", () => { }), ).rejects.toThrow(/Email is already taken/); }); + + test<CustomTestContext>("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); +}); |
