From 50a89d4104a7515fc1b428bb75ca1b9f039a8e85 Mon Sep 17 00:00:00 2001 From: Ryan Pandya Date: Thu, 14 Nov 2024 21:09:59 -0800 Subject: [PATCH] Admin interface MVP --- apps/cli/src/commands/hello-world.ts | 27 ---- apps/cli/src/commands/whoami.ts | 21 ++++ apps/cli/src/index.ts | 7 +- apps/cli/src/lib/globals.ts | 2 +- apps/web/components/HoarderIcon.tsx | 2 +- .../dashboard/admin/AddUserDialog.tsx | 2 +- .../dashboard/admin/AdminActions.tsx | 117 +---------------- .../dashboard/admin/ChangeRoleDialog.tsx | 2 +- .../dashboard/admin/ResetPasswordDialog.tsx | 2 +- .../dashboard/admin/ServerStats.tsx | 31 +---- .../components/dashboard/admin/UserList.tsx | 8 +- .../cleanups/TagDuplicationDetention.tsx | 2 +- apps/web/server/api/client.ts | 7 +- packages/db/drizzle.config.ts | 2 - packages/db/drizzle.ts | 5 +- packages/shared/config.ts | 5 +- packages/shared/types/admin.ts | 25 ++++ packages/trpc/routers/_app.ts | 2 + packages/trpc/routers/admin.ts | 118 ++++++++++++++++++ 19 files changed, 196 insertions(+), 191 deletions(-) delete mode 100644 apps/cli/src/commands/hello-world.ts create mode 100644 apps/cli/src/commands/whoami.ts create mode 100644 packages/shared/types/admin.ts create mode 100644 packages/trpc/routers/admin.ts diff --git a/apps/cli/src/commands/hello-world.ts b/apps/cli/src/commands/hello-world.ts deleted file mode 100644 index ff7289d..0000000 --- a/apps/cli/src/commands/hello-world.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - printError, - printErrorMessageWithReason, - printObject, -} from "@/lib/output"; -import { getAPIClient } from "@/lib/trpc"; -import { Command } from "@commander-js/extra-typings"; - -export const helloWorldCmd = new Command() - .name("hello-world") - .description("returns info about the server and stats"); - -helloWorldCmd - .command("test") - .description("does something specific I guess") - .action(async () => { - const api = getAPIClient(); - const whoami = await api.users.whoami.query(); - try { - console.log("Hello " + whoami.name); - } catch (error) { - printErrorMessageWithReason( - "Something went horribly wrong", - error as object, - ); - } - }); diff --git a/apps/cli/src/commands/whoami.ts b/apps/cli/src/commands/whoami.ts new file mode 100644 index 0000000..9cc6755 --- /dev/null +++ b/apps/cli/src/commands/whoami.ts @@ -0,0 +1,21 @@ +import { + printError, + printErrorMessageWithReason, + printObject, +} from "@/lib/output"; +import { getAPIClient } from "@/lib/trpc"; +import { Command } from "@commander-js/extra-typings"; + +export const whoamiCmd = new Command() + .name("whoami") + .description("returns info about the owner of this API key") + .action(async () => { + await getAPIClient() + .users.whoami.query() + .then(printObject) + .catch( + printError( + `Unable to fetch information about the owner of this API key`, + ), + ); + }); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index ffe711f..dbf0b45 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,4 +1,4 @@ -import { helloWorldCmd } from "@/commands/hello-world" +import { whoamiCmd } from "@/commands/whoami"; import { setGlobalOptions } from "@/lib/globals"; import { Command, Option } from "@commander-js/extra-typings"; @@ -25,7 +25,10 @@ const program = new Command() : "0.0.0", ); -program.addCommand(helloWorldCmd); +program.addCommand(whoamiCmd, { + isDefault: true +}); + setGlobalOptions(program.opts()); program.parse(); diff --git a/apps/cli/src/lib/globals.ts b/apps/cli/src/lib/globals.ts index 8a301cf..8942056 100644 --- a/apps/cli/src/lib/globals.ts +++ b/apps/cli/src/lib/globals.ts @@ -1,7 +1,7 @@ export interface GlobalOptions { apiKey: string; serverAddr: string; - json?: true; + json?: false; } export let globalOpts: GlobalOptions | undefined = undefined; diff --git a/apps/web/components/HoarderIcon.tsx b/apps/web/components/HoarderIcon.tsx index d643e24..f069129 100644 --- a/apps/web/components/HoarderIcon.tsx +++ b/apps/web/components/HoarderIcon.tsx @@ -9,7 +9,7 @@ export default function LifetrackerLogo({ return ( Lifetracker diff --git a/apps/web/components/dashboard/admin/AddUserDialog.tsx b/apps/web/components/dashboard/admin/AddUserDialog.tsx index a13c6b8..b11cdd8 100644 --- a/apps/web/components/dashboard/admin/AddUserDialog.tsx +++ b/apps/web/components/dashboard/admin/AddUserDialog.tsx @@ -33,7 +33,7 @@ import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { zAdminCreateUserSchema } from "@hoarder/shared/types/admin"; +import { zAdminCreateUserSchema } from "@lifetracker/shared/types/admin"; type AdminCreateUserSchema = z.infer; diff --git a/apps/web/components/dashboard/admin/AdminActions.tsx b/apps/web/components/dashboard/admin/AdminActions.tsx index a97552f..31b0e86 100644 --- a/apps/web/components/dashboard/admin/AdminActions.tsx +++ b/apps/web/components/dashboard/admin/AdminActions.tsx @@ -5,67 +5,6 @@ import { toast } from "@/components/ui/use-toast"; import { api } from "@/lib/trpc"; export default function AdminActions() { - const { mutate: recrawlLinks, isPending: isRecrawlPending } = - api.admin.recrawlLinks.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, - }); - }, - }); - - const { - mutate: reRunInferenceOnAllBookmarks, - isPending: isInferencePending, - } = api.admin.reRunInferenceOnAllBookmarks.useMutation({ - onSuccess: () => { - toast({ - description: "Inference jobs enqueued", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); - - const { mutateAsync: tidyAssets, isPending: isTidyAssetsPending } = - api.admin.tidyAssets.useMutation({ - onSuccess: () => { - toast({ - description: "Tidy assets request has been enqueued!", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); return (
@@ -73,60 +12,10 @@ export default function AdminActions() {
- recrawlLinks({ crawlStatus: "failure", runInference: true }) - } + loading={false} + onClick={() => alert("This does nothing yet")} > - Recrawl Failed Links Only - - - recrawlLinks({ crawlStatus: "all", runInference: true }) - } - > - Recrawl All Links - - - recrawlLinks({ crawlStatus: "all", runInference: false }) - } - > - Recrawl All Links (Without Inference) - - - reRunInferenceOnAllBookmarks({ taggingStatus: "failure" }) - } - > - Regenerate AI Tags for Failed Bookmarks Only - - reRunInferenceOnAllBookmarks({ taggingStatus: "all" })} - > - Regenerate AI Tags for All Bookmarks - - reindexBookmarks()} - > - Reindex All Bookmarks - - tidyAssets()} - > - Compact Assets + TODO: Add Stuff Here
diff --git a/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx b/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx index 26ad5dc..d87b471 100644 --- a/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx +++ b/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx @@ -32,7 +32,7 @@ import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { changeRoleSchema } from "@hoarder/shared/types/admin"; +import { changeRoleSchema } from "@lifetracker/shared/types/admin"; type ChangeRoleSchema = z.infer; diff --git a/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx b/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx index 32183d1..ed83fdc 100644 --- a/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx +++ b/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx @@ -26,7 +26,7 @@ import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { resetPasswordSchema } from "@hoarder/shared/types/admin"; +import { resetPasswordSchema } from "@lifetracker/shared/types/admin"; interface ResetPasswordDialogProps { userId: string; diff --git a/apps/web/components/dashboard/admin/ServerStats.tsx b/apps/web/components/dashboard/admin/ServerStats.tsx index f45d86c..d1b372c 100644 --- a/apps/web/components/dashboard/admin/ServerStats.tsx +++ b/apps/web/components/dashboard/admin/ServerStats.tsx @@ -14,8 +14,8 @@ import { api } from "@/lib/trpc"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; const REPO_LATEST_RELEASE_API = - "https://api.github.com/repos/hoarder-app/hoarder/releases/latest"; -const REPO_RELEASE_PAGE = "https://github.com/hoarder-app/hoarder/releases"; + "https://git.ryanpandya.com/repos/ryan/lifetracker/releases/latest"; +const REPO_RELEASE_PAGE = "https://git.ryanpandya.com/ryan/lifetracker/releases"; function useLatestRelease() { const { data } = useQuery({ @@ -55,7 +55,7 @@ function ReleaseInfo() { return (
{currentRelease} - {newRelease} + {/* {newRelease} */}
); } @@ -83,7 +83,7 @@ export default function ServerStats() { Total Bookmarks
- {serverStats.numBookmarks} + {serverStats.numEntries}
@@ -105,28 +105,7 @@ export default function ServerStats() { - Crawling Jobs - {serverStats.crawlStats.queued} - {serverStats.crawlStats.pending} - {serverStats.crawlStats.failed} - - - Indexing Jobs - {serverStats.indexingStats.queued} - - - - - - - Inference Jobs - {serverStats.inferenceStats.queued} - {serverStats.inferenceStats.pending} - {serverStats.inferenceStats.failed} - - - Tidy Assets Jobs - {serverStats.tidyAssetsStats.queued} - - - - + No workers yet... diff --git a/apps/web/components/dashboard/admin/UserList.tsx b/apps/web/components/dashboard/admin/UserList.tsx index 2937df2..cadb20e 100644 --- a/apps/web/components/dashboard/admin/UserList.tsx +++ b/apps/web/components/dashboard/admin/UserList.tsx @@ -67,8 +67,8 @@ export default function UsersSection() { Name Email - Num Bookmarks - Asset Sizes + Days on Record + Entries Role Local User Actions @@ -79,10 +79,10 @@ export default function UsersSection() { {u.name} {u.email} - {userStats[u.id].numBookmarks} + {userStats[u.id].numDays} - {toHumanReadableSize(userStats[u.id].assetSizes)} + {userStats[u.id].numEntries} {u.role} diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx index 61132a6..f5139fb 100644 --- a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx +++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx @@ -26,7 +26,7 @@ import { cn } from "@/lib/utils"; import { distance } from "fastest-levenshtein"; import { Check, Combine, X } from "lucide-react"; -import { useMergeTag } from "@hoarder/shared-react/hooks/tags"; +import { useMergeTag } from "@lifetracker/shared-react/hooks/tags"; interface Suggestion { mergeIntoId: string; diff --git a/apps/web/server/api/client.ts b/apps/web/server/api/client.ts index 0532e64..a999ae5 100644 --- a/apps/web/server/api/client.ts +++ b/apps/web/server/api/client.ts @@ -17,11 +17,6 @@ export async function createContextFromRequest(req: Request) { if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { const token = authorizationHeader.split(" ")[1]; try { - - console.log("\n\nEeeeeeentering hell\n\n"); - console.log(await authenticateApiKey(token)); - console.log("\n\n\n\n"); - const user = await authenticateApiKey(token); return { user, @@ -44,7 +39,7 @@ export const createContext = async ( ): Promise => { const session = await getServerAuthSession(); if (ip === undefined) { - const hdrs = headers(); + const hdrs = await headers(); ip = requestIp.getClientIp({ headers: Object.fromEntries(hdrs.entries()), }); diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index f02c071..e2f24d3 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -2,8 +2,6 @@ import dotenv from "dotenv"; import type { Config } from "drizzle-kit"; import serverConfig from "@lifetracker/shared/config"; - - const databaseURL = serverConfig.dataDir ? `${serverConfig.dataDir}/lifetracker.db` : "./lifetracker.db"; diff --git a/packages/db/drizzle.ts b/packages/db/drizzle.ts index 8c9fc28..af86231 100644 --- a/packages/db/drizzle.ts +++ b/packages/db/drizzle.ts @@ -8,7 +8,10 @@ import path from "path"; import dbConfig from "./drizzle.config"; const sqlite = new Database(dbConfig.dbCredentials.url); -export const db = drizzle(sqlite, { schema, logger: true }); +export const db = drizzle(sqlite, { + schema, + // logger: true +}); export function getInMemoryDB(runMigrations: boolean) { const mem = new Database(":memory:"); diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 92c5d2f..e3c8117 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -1,9 +1,8 @@ import { z } from "zod"; import dotenv from "dotenv"; -console.log(dotenv.config({ +dotenv.config({ path: ".env.local", - debug: true -})); +}); const stringBool = (defaultValue: string) => z .string() diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts new file mode 100644 index 0000000..0f29ed4 --- /dev/null +++ b/packages/shared/types/admin.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +import { PASSWORD_MAX_LENGTH, zSignUpSchema } from "./users"; + +export const zRoleSchema = z.object({ + role: z.enum(["user", "admin"]), +}); + +export const zAdminCreateUserSchema = zSignUpSchema.and(zRoleSchema); + +export const changeRoleSchema = z.object({ + userId: z.string(), + role: z.enum(["user", "admin"]), +}); + +export const resetPasswordSchema = z + .object({ + userId: z.string(), + newPassword: z.string().min(8).max(PASSWORD_MAX_LENGTH), + newPasswordConfirm: z.string(), + }) + .refine((data) => data.newPassword === data.newPasswordConfirm, { + message: "Passwords don't match", + path: ["newPasswordConfirm"], + }); \ No newline at end of file diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index f1397dd..bafafa7 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -2,10 +2,12 @@ import { apiKeys } from "@lifetracker/db/schema"; import { router } from "../index"; import { usersAppRouter } from "./users"; import { apiKeysAppRouter } from "./apiKeys"; +import { adminAppRouter } from "./admin"; export const appRouter = router({ users: usersAppRouter, apiKeys: apiKeysAppRouter, + admin: adminAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; \ No newline at end of file diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts new file mode 100644 index 0000000..e2f3edb --- /dev/null +++ b/packages/trpc/routers/admin.ts @@ -0,0 +1,118 @@ +import { TRPCError } from "@trpc/server"; +import { count, eq, sum } from "drizzle-orm"; +import { z } from "zod"; + +import { users } from "@lifetracker/db/schema"; +import { + changeRoleSchema, + resetPasswordSchema, + zAdminCreateUserSchema, +} from "@lifetracker/shared/types/admin"; + +import { hashPassword } from "../auth"; +import { adminProcedure, router } from "../index"; +import { createUser } from "./users"; + +export const adminAppRouter = router({ + stats: adminProcedure + .output( + z.object({ + numUsers: z.number(), + numEntries: z.number(), + }), + ) + .query(async ({ ctx }) => { + const [ + [{ value: numUsers }], + ] = await Promise.all([ + ctx.db.select({ value: count() }).from(users)]); + + return { + numUsers, + numEntries: 420.69, + }; + }), + userStats: adminProcedure + .output( + z.record( + z.string(), + z.object({ + numDays: z.number(), + numEntries: z.number(), + }), + ), + ) + .query(async ({ ctx }) => { + const [userIds] = await Promise.all([ + ctx.db.select({ id: users.id }).from(users) + ]); + + const results: Record< + string, + { numDays: number; numEntries: number } + > = {}; + for (const user of userIds) { + results[user.id] = { + numDays: 0, + numEntries: 0, + }; + } + return results; + }), + createUser: adminProcedure + .input(zAdminCreateUserSchema) + .output( + z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + role: z.enum(["user", "admin"]).nullable(), + }), + ) + .mutation(async ({ input, ctx }) => { + return createUser(input, ctx, input.role); + }), + changeRole: adminProcedure + .input(changeRoleSchema) + .mutation(async ({ input, ctx }) => { + if (ctx.user.id == input.userId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot change own role", + }); + } + const result = await ctx.db + .update(users) + .set({ role: input.role }) + .where(eq(users.id, input.userId)); + + if (!result.changes) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + }), + resetPassword: adminProcedure + .input(resetPasswordSchema) + .mutation(async ({ input, ctx }) => { + if (ctx.user.id == input.userId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot reset own password", + }); + } + const hashedPassword = await hashPassword(input.newPassword); + const result = await ctx.db + .update(users) + .set({ password: hashedPassword }) + .where(eq(users.id, input.userId)); + + if (result.changes == 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + }), +}); \ No newline at end of file