aboutsummaryrefslogtreecommitdiffstats
path: root/packages/web
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web')
-rw-r--r--packages/web/app/dashboard/admin/page.tsx123
-rw-r--r--packages/web/app/dashboard/components/Sidebar.tsx8
-rw-r--r--packages/web/app/layout.tsx6
-rw-r--r--packages/web/lib/providers.tsx20
-rw-r--r--packages/web/lib/testUtils.ts2
-rw-r--r--packages/web/server/api/routers/_app.ts2
-rw-r--r--packages/web/server/api/routers/admin.ts86
-rw-r--r--packages/web/server/auth.ts7
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;