Admin interface MVP
This commit is contained in:
parent
ea2a9da1e0
commit
50a89d4104
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
21
apps/cli/src/commands/whoami.ts
Normal file
21
apps/cli/src/commands/whoami.ts
Normal file
@ -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`,
|
||||
),
|
||||
);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export interface GlobalOptions {
|
||||
apiKey: string;
|
||||
serverAddr: string;
|
||||
json?: true;
|
||||
json?: false;
|
||||
}
|
||||
|
||||
export let globalOpts: GlobalOptions | undefined = undefined;
|
||||
|
||||
@ -9,7 +9,7 @@ export default function LifetrackerLogo({
|
||||
return (
|
||||
<span style={{ gap }} className="flex items-center">
|
||||
<span
|
||||
style={{ fontSize: "50px" }}
|
||||
style={{ fontSize: "35px" }}
|
||||
className={`fill-foreground`}
|
||||
>Lifetracker</span>
|
||||
</span>
|
||||
|
||||
@ -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<typeof zAdminCreateUserSchema>;
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div>
|
||||
@ -73,60 +12,10 @@ export default function AdminActions() {
|
||||
<div className="flex flex-col gap-2 sm:w-1/2">
|
||||
<ActionButton
|
||||
variant="destructive"
|
||||
loading={isRecrawlPending}
|
||||
onClick={() =>
|
||||
recrawlLinks({ crawlStatus: "failure", runInference: true })
|
||||
}
|
||||
loading={false}
|
||||
onClick={() => alert("This does nothing yet")}
|
||||
>
|
||||
Recrawl Failed Links Only
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="destructive"
|
||||
loading={isRecrawlPending}
|
||||
onClick={() =>
|
||||
recrawlLinks({ crawlStatus: "all", runInference: true })
|
||||
}
|
||||
>
|
||||
Recrawl All Links
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="destructive"
|
||||
loading={isRecrawlPending}
|
||||
onClick={() =>
|
||||
recrawlLinks({ crawlStatus: "all", runInference: false })
|
||||
}
|
||||
>
|
||||
Recrawl All Links (Without Inference)
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="destructive"
|
||||
loading={isInferencePending}
|
||||
onClick={() =>
|
||||
reRunInferenceOnAllBookmarks({ taggingStatus: "failure" })
|
||||
}
|
||||
>
|
||||
Regenerate AI Tags for Failed Bookmarks Only
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="destructive"
|
||||
loading={isInferencePending}
|
||||
onClick={() => reRunInferenceOnAllBookmarks({ taggingStatus: "all" })}
|
||||
>
|
||||
Regenerate AI Tags for All Bookmarks
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="destructive"
|
||||
loading={isReindexPending}
|
||||
onClick={() => reindexBookmarks()}
|
||||
>
|
||||
Reindex All Bookmarks
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="destructive"
|
||||
loading={isTidyAssetsPending}
|
||||
onClick={() => tidyAssets()}
|
||||
>
|
||||
Compact Assets
|
||||
TODO: Add Stuff Here
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<typeof changeRoleSchema>;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 (
|
||||
<div className="text-nowrap">
|
||||
<span className="text-3xl font-semibold">{currentRelease}</span>
|
||||
<span className="ml-1 text-sm">{newRelease}</span>
|
||||
{/* <span className="ml-1 text-sm">{newRelease}</span> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -83,7 +83,7 @@ export default function ServerStats() {
|
||||
Total Bookmarks
|
||||
</div>
|
||||
<div className="text-3xl font-semibold">
|
||||
{serverStats.numBookmarks}
|
||||
{serverStats.numEntries}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-4 sm:w-1/4">
|
||||
@ -105,28 +105,7 @@ export default function ServerStats() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="lg:w-2/3">Crawling Jobs</TableCell>
|
||||
<TableCell>{serverStats.crawlStats.queued}</TableCell>
|
||||
<TableCell>{serverStats.crawlStats.pending}</TableCell>
|
||||
<TableCell>{serverStats.crawlStats.failed}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Indexing Jobs</TableCell>
|
||||
<TableCell>{serverStats.indexingStats.queued}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Inference Jobs</TableCell>
|
||||
<TableCell>{serverStats.inferenceStats.queued}</TableCell>
|
||||
<TableCell>{serverStats.inferenceStats.pending}</TableCell>
|
||||
<TableCell>{serverStats.inferenceStats.failed}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Tidy Assets Jobs</TableCell>
|
||||
<TableCell>{serverStats.tidyAssetsStats.queued}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell className="lg:w-2/3">No workers yet...</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@ -67,8 +67,8 @@ export default function UsersSection() {
|
||||
<TableHeader className="bg-gray-200">
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Num Bookmarks</TableHead>
|
||||
<TableHead>Asset Sizes</TableHead>
|
||||
<TableHead>Days on Record</TableHead>
|
||||
<TableHead>Entries</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Local User</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
@ -79,10 +79,10 @@ export default function UsersSection() {
|
||||
<TableCell className="py-1">{u.name}</TableCell>
|
||||
<TableCell className="py-1">{u.email}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
{userStats[u.id].numBookmarks}
|
||||
{userStats[u.id].numDays}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
{toHumanReadableSize(userStats[u.id].assetSizes)}
|
||||
{userStats[u.id].numEntries}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 capitalize">{u.role}</TableCell>
|
||||
<TableCell className="py-1 capitalize">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<Context> => {
|
||||
const session = await getServerAuthSession();
|
||||
if (ip === undefined) {
|
||||
const hdrs = headers();
|
||||
const hdrs = await headers();
|
||||
ip = requestIp.getClientIp({
|
||||
headers: Object.fromEntries(hdrs.entries()),
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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:");
|
||||
|
||||
@ -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()
|
||||
|
||||
25
packages/shared/types/admin.ts
Normal file
25
packages/shared/types/admin.ts
Normal file
@ -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"],
|
||||
});
|
||||
@ -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;
|
||||
118
packages/trpc/routers/admin.ts
Normal file
118
packages/trpc/routers/admin.ts
Normal file
@ -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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user