diff options
Diffstat (limited to 'packages/web')
| -rw-r--r-- | packages/web/app/dashboard/admin/page.tsx | 123 | ||||
| -rw-r--r-- | packages/web/app/dashboard/components/Sidebar.tsx | 8 | ||||
| -rw-r--r-- | packages/web/app/layout.tsx | 6 | ||||
| -rw-r--r-- | packages/web/lib/providers.tsx | 20 | ||||
| -rw-r--r-- | packages/web/lib/testUtils.ts | 2 | ||||
| -rw-r--r-- | packages/web/server/api/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/web/server/api/routers/admin.ts | 86 | ||||
| -rw-r--r-- | packages/web/server/auth.ts | 7 |
8 files changed, 247 insertions, 7 deletions
diff --git a/packages/web/app/dashboard/admin/page.tsx b/packages/web/app/dashboard/admin/page.tsx new file mode 100644 index 00000000..26b6447b --- /dev/null +++ b/packages/web/app/dashboard/admin/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { keepPreviousData } from "@tanstack/react-query"; +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, + }); + + const { mutate: recrawlLinks, isPending: isRecrawlPending } = + api.admin.recrawlAllLinks.useMutation({ + onSuccess: () => { + toast({ + description: "Recrawl enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + const { mutate: reindexBookmarks, isPending: isReindexPending } = + api.admin.reindexAllBookmarks.useMutation({ + onSuccess: () => { + toast({ + description: "Reindex enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + 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 ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead>Stats</TableHead> + <TableHead>Value</TableHead> + </TableRow> + </TableHeader> + <TableBody> + <TableRow> + <TableCell>Num Users</TableCell> + <TableCell>{data.numUsers}</TableCell> + </TableRow> + <TableRow> + <TableCell>Num Bookmarks</TableCell> + <TableCell>{data.numBookmarks}</TableCell> + </TableRow> + <TableRow> + <TableCell>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 + variant="destructive" + loading={isRecrawlPending} + onClick={() => recrawlLinks()} + > + Recrawl All Links + </ActionButton> + <ActionButton + variant="destructive" + loading={isReindexPending} + onClick={() => reindexBookmarks()} + > + Reindex All Bookmarks + </ActionButton> + </div> + ); +} diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx index 010ee103..29e5baed 100644 --- a/packages/web/app/dashboard/components/Sidebar.tsx +++ b/packages/web/app/dashboard/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { PackageOpen, Settings, Search, + Shield, } from "lucide-react"; import { redirect } from "next/navigation"; import SidebarItem from "./SidebarItem"; @@ -61,6 +62,13 @@ export default async function Sidebar() { name="Settings" path="/dashboard/settings" /> + {session.user.role == "admin" && ( + <SidebarItem + logo={<Shield />} + name="Admin" + path="/dashboard/admin" + /> + )} </ul> </div> <Separator /> diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx index d597063b..a8423031 100644 --- a/packages/web/app/layout.tsx +++ b/packages/web/app/layout.tsx @@ -5,6 +5,7 @@ import React from "react"; import { Toaster } from "@/components/ui/toaster"; import Providers from "@/lib/providers"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { getServerAuthSession } from "@/server/auth"; const inter = Inter({ subsets: ["latin"] }); @@ -13,15 +14,16 @@ export const metadata: Metadata = { description: "Your AI powered second brain", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const session = await getServerAuthSession(); return ( <html lang="en"> <body className={inter.className}> - <Providers> + <Providers session={session}> {children} <ReactQueryDevtools initialIsOpen={false} /> </Providers> diff --git a/packages/web/lib/providers.tsx b/packages/web/lib/providers.tsx index d14d4d96..0c721c1e 100644 --- a/packages/web/lib/providers.tsx +++ b/packages/web/lib/providers.tsx @@ -6,8 +6,16 @@ import { api } from "./trpc"; import { loggerLink } from "@trpc/client"; import { httpBatchLink } from "@trpc/client"; import superjson from "superjson"; +import { SessionProvider } from "next-auth/react"; +import { Session } from "next-auth"; -export default function Providers({ children }: { children: React.ReactNode }) { +export default function Providers({ + children, + session, +}: { + children: React.ReactNode; + session: Session | null; +}) { const [queryClient] = React.useState(() => new QueryClient()); const [trpcClient] = useState(() => @@ -28,8 +36,12 @@ export default function Providers({ children }: { children: React.ReactNode }) { ); return ( - <api.Provider client={trpcClient} queryClient={queryClient}> - <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> - </api.Provider> + <SessionProvider session={session}> + <api.Provider client={trpcClient} queryClient={queryClient}> + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + </api.Provider> + </SessionProvider> ); } diff --git a/packages/web/lib/testUtils.ts b/packages/web/lib/testUtils.ts index ca9a6474..142ad844 100644 --- a/packages/web/lib/testUtils.ts +++ b/packages/web/lib/testUtils.ts @@ -2,7 +2,6 @@ import { users } from "@hoarder/db/schema"; import { getInMemoryDB } from "@hoarder/db/drizzle"; import { appRouter } from "@/server/api/routers/_app"; import { createCallerFactory } from "@/server/api/trpc"; -import { beforeEach } from "vitest"; export function getTestDB() { return getInMemoryDB(true); @@ -31,6 +30,7 @@ export function getApiCaller(db: TestDB, userId: string) { return createCaller({ user: { id: userId, + role: "user", }, db, }); diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts index 6a1b05e9..43ab6f5d 100644 --- a/packages/web/server/api/routers/_app.ts +++ b/packages/web/server/api/routers/_app.ts @@ -1,4 +1,5 @@ import { router } from "../trpc"; +import { adminAppRouter } from "./admin"; import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; import { listsAppRouter } from "./lists"; @@ -8,6 +9,7 @@ export const appRouter = router({ apiKeys: apiKeysAppRouter, users: usersAppRouter, lists: listsAppRouter, + admin: adminAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/web/server/api/routers/admin.ts b/packages/web/server/api/routers/admin.ts new file mode 100644 index 00000000..92769151 --- /dev/null +++ b/packages/web/server/api/routers/admin.ts @@ -0,0 +1,86 @@ +import { authedProcedure, 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 { + LinkCrawlerQueue, + OpenAIQueue, + 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( + z.object({ + numUsers: z.number(), + numBookmarks: z.number(), + pendingCrawls: z.number(), + pendingIndexing: z.number(), + pendingOpenai: z.number(), + }), + ) + .query(async ({ ctx }) => { + const [ + [{ value: numUsers }], + [{ value: numBookmarks }], + pendingCrawls, + pendingIndexing, + pendingOpenai, + ] = await Promise.all([ + ctx.db.select({ value: count() }).from(users), + ctx.db.select({ value: count() }).from(bookmarks), + LinkCrawlerQueue.getWaitingCount(), + SearchIndexingQueue.getWaitingCount(), + OpenAIQueue.getWaitingCount(), + ]); + + return { + numUsers, + numBookmarks, + pendingCrawls, + pendingIndexing, + pendingOpenai, + }; + }), + recrawlAllLinks: adminProcedure.mutation(async ({ ctx }) => { + const bookmarkIds = await ctx.db.query.bookmarkLinks.findMany({ + columns: { + id: true, + }, + }); + + await Promise.all( + bookmarkIds.map((b) => + LinkCrawlerQueue.add("crawl", { + bookmarkId: b.id, + }), + ), + ); + }), + + reindexAllBookmarks: adminProcedure.mutation(async ({ ctx }) => { + const bookmarkIds = await ctx.db.query.bookmarks.findMany({ + columns: { + id: true, + }, + }); + + await Promise.all( + bookmarkIds.map((b) => + SearchIndexingQueue.add("search_indexing", { + bookmarkId: b.id, + type: "index", + }), + ), + ); + }), +}); diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts index f2c78190..1810c87d 100644 --- a/packages/web/server/auth.ts +++ b/packages/web/server/auth.ts @@ -16,6 +16,7 @@ declare module "next-auth/jwt" { export interface JWT { user: { id: string; + role: "admin" | "user"; } & DefaultSession["user"]; } } @@ -27,8 +28,13 @@ declare module "next-auth" { export interface Session { user: { id: string; + role: "admin" | "user"; } & DefaultSession["user"]; } + + export interface DefaultUser { + role: "admin" | "user" | null; + } } const providers: Provider[] = [ @@ -75,6 +81,7 @@ export const authOptions: NextAuthOptions = { name: user.name, email: user.email, image: user.image, + role: user.role || "user", }; } return token; |
