From 179f00b15525b024b6823088ef8fb94b7106b4f0 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 30 Dec 2024 11:27:32 +0000 Subject: feat: Change the admin page to be tabbed similar to that of the settings page --- apps/web/app/admin/actions/page.tsx | 5 + apps/web/app/admin/layout.tsx | 40 ++++ apps/web/app/admin/overview/page.tsx | 5 + apps/web/app/admin/page.tsx | 6 + apps/web/app/admin/users/page.tsx | 5 + apps/web/app/dashboard/admin/page.tsx | 26 --- apps/web/components/admin/AddUserDialog.tsx | 213 +++++++++++++++++++++ apps/web/components/admin/AdminActions.tsx | 137 +++++++++++++ apps/web/components/admin/AdminCard.tsx | 3 + apps/web/components/admin/AdminNotices.tsx | 71 +++++++ apps/web/components/admin/ChangeRoleDialog.tsx | 154 +++++++++++++++ apps/web/components/admin/ResetPasswordDialog.tsx | 145 ++++++++++++++ apps/web/components/admin/ServerStats.tsx | 148 ++++++++++++++ apps/web/components/admin/UserList.tsx | 130 +++++++++++++ .../web/components/admin/sidebar/MobileSidebar.tsx | 21 ++ apps/web/components/admin/sidebar/Sidebar.tsx | 36 ++++ apps/web/components/admin/sidebar/items.tsx | 31 +++ .../components/dashboard/admin/AddUserDialog.tsx | 213 --------------------- .../components/dashboard/admin/AdminActions.tsx | 137 ------------- apps/web/components/dashboard/admin/AdminCard.tsx | 3 - .../components/dashboard/admin/AdminNotices.tsx | 71 ------- .../dashboard/admin/ChangeRoleDialog.tsx | 154 --------------- .../dashboard/admin/ResetPasswordDialog.tsx | 145 -------------- .../web/components/dashboard/admin/ServerStats.tsx | 148 -------------- apps/web/components/dashboard/admin/UserList.tsx | 130 ------------- .../components/dashboard/header/ProfileOptions.tsx | 4 +- 26 files changed, 1152 insertions(+), 1029 deletions(-) create mode 100644 apps/web/app/admin/actions/page.tsx create mode 100644 apps/web/app/admin/layout.tsx create mode 100644 apps/web/app/admin/overview/page.tsx create mode 100644 apps/web/app/admin/page.tsx create mode 100644 apps/web/app/admin/users/page.tsx delete mode 100644 apps/web/app/dashboard/admin/page.tsx create mode 100644 apps/web/components/admin/AddUserDialog.tsx create mode 100644 apps/web/components/admin/AdminActions.tsx create mode 100644 apps/web/components/admin/AdminCard.tsx create mode 100644 apps/web/components/admin/AdminNotices.tsx create mode 100644 apps/web/components/admin/ChangeRoleDialog.tsx create mode 100644 apps/web/components/admin/ResetPasswordDialog.tsx create mode 100644 apps/web/components/admin/ServerStats.tsx create mode 100644 apps/web/components/admin/UserList.tsx create mode 100644 apps/web/components/admin/sidebar/MobileSidebar.tsx create mode 100644 apps/web/components/admin/sidebar/Sidebar.tsx create mode 100644 apps/web/components/admin/sidebar/items.tsx delete mode 100644 apps/web/components/dashboard/admin/AddUserDialog.tsx delete mode 100644 apps/web/components/dashboard/admin/AdminActions.tsx delete mode 100644 apps/web/components/dashboard/admin/AdminCard.tsx delete mode 100644 apps/web/components/dashboard/admin/AdminNotices.tsx delete mode 100644 apps/web/components/dashboard/admin/ChangeRoleDialog.tsx delete mode 100644 apps/web/components/dashboard/admin/ResetPasswordDialog.tsx delete mode 100644 apps/web/components/dashboard/admin/ServerStats.tsx delete mode 100644 apps/web/components/dashboard/admin/UserList.tsx (limited to 'apps') diff --git a/apps/web/app/admin/actions/page.tsx b/apps/web/app/admin/actions/page.tsx new file mode 100644 index 00000000..51f7e5d4 --- /dev/null +++ b/apps/web/app/admin/actions/page.tsx @@ -0,0 +1,5 @@ +import AdminActions from "@/components/admin/AdminActions"; + +export default function AdminActionsPage() { + return ; +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 00000000..0d876736 --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation"; +import { AdminCard } from "@/components/admin/AdminCard"; +import { AdminNotices } from "@/components/admin/AdminNotices"; +import MobileAdminSidebar from "@/components/admin/sidebar/MobileSidebar"; +import AdminSidebar from "@/components/admin/sidebar/Sidebar"; +import Header from "@/components/dashboard/header/Header"; +import { Separator } from "@/components/ui/separator"; +import { getServerAuthSession } from "@/server/auth"; + +export default async function AdminLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const session = await getServerAuthSession(); + if (!session || session.user.role !== "admin") { + redirect("/"); + } + + return ( +
+
+
+
+ +
+
+
+ + +
+
+ + {children} +
+
+
+
+ ); +} diff --git a/apps/web/app/admin/overview/page.tsx b/apps/web/app/admin/overview/page.tsx new file mode 100644 index 00000000..226fb9d5 --- /dev/null +++ b/apps/web/app/admin/overview/page.tsx @@ -0,0 +1,5 @@ +import ServerStats from "@/components/admin/ServerStats"; + +export default function AdminOverviewPage() { + return ; +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 00000000..7fed8185 --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default function AdminHomepage() { + redirect("/admin/overview"); + return null; +} diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx new file mode 100644 index 00000000..be5cfe81 --- /dev/null +++ b/apps/web/app/admin/users/page.tsx @@ -0,0 +1,5 @@ +import UserList from "@/components/admin/UserList"; + +export default function AdminUsersPage() { + return ; +} diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx deleted file mode 100644 index cf97698b..00000000 --- a/apps/web/app/dashboard/admin/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { redirect } from "next/navigation"; -import AdminActions from "@/components/dashboard/admin/AdminActions"; -import { AdminCard } from "@/components/dashboard/admin/AdminCard"; -import { AdminNotices } from "@/components/dashboard/admin/AdminNotices"; -import ServerStats from "@/components/dashboard/admin/ServerStats"; -import UserList from "@/components/dashboard/admin/UserList"; -import { getServerAuthSession } from "@/server/auth"; - -export default async function AdminPage() { - const session = await getServerAuthSession(); - if (!session || session.user.role !== "admin") { - redirect("/"); - } - return ( -
- - - - - - - - -
- ); -} diff --git a/apps/web/components/admin/AddUserDialog.tsx b/apps/web/components/admin/AddUserDialog.tsx new file mode 100644 index 00000000..a13c6b88 --- /dev/null +++ b/apps/web/components/admin/AddUserDialog.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { zAdminCreateUserSchema } from "@hoarder/shared/types/admin"; + +type AdminCreateUserSchema = z.infer; + +export default function AddUserDialog({ + children, +}: { + children?: React.ReactNode; +}) { + const apiUtils = api.useUtils(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm({ + resolver: zodResolver(zAdminCreateUserSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + role: "user", + }, + }); + const { mutate, isPending } = api.admin.createUser.useMutation({ + onSuccess: () => { + toast({ + description: "User created successfully", + }); + onOpenChange(false); + apiUtils.users.list.invalidate(); + apiUtils.admin.userStats.invalidate(); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to create user", + }); + } + }, + }); + + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + + {children} + + + Add User + +
+ mutate(val))}> +
+ ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + ( + + Role + + + + + + )} + /> + + + + + + Create + + +
+
+ +
+
+ ); +} diff --git a/apps/web/components/admin/AdminActions.tsx b/apps/web/components/admin/AdminActions.tsx new file mode 100644 index 00000000..34b3d63a --- /dev/null +++ b/apps/web/components/admin/AdminActions.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; + +export default function AdminActions() { + const { t } = useTranslation(); + 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 ( +
+
{t("common.actions")}
+
+ + recrawlLinks({ crawlStatus: "failure", runInference: true }) + } + > + {t("admin.actions.recrawl_failed_links_only")} + + + recrawlLinks({ crawlStatus: "all", runInference: true }) + } + > + {t("admin.actions.recrawl_all_links")} + + + recrawlLinks({ crawlStatus: "all", runInference: false }) + } + > + {t("admin.actions.recrawl_all_links")} ( + {t("admin.actions.without_inference")}) + + + reRunInferenceOnAllBookmarks({ taggingStatus: "failure" }) + } + > + {t("admin.actions.regenerate_ai_tags_for_failed_bookmarks_only")} + + reRunInferenceOnAllBookmarks({ taggingStatus: "all" })} + > + {t("admin.actions.regenerate_ai_tags_for_all_bookmarks")} + + reindexBookmarks()} + > + {t("admin.actions.reindex_all_bookmarks")} + + tidyAssets()} + > + {t("admin.actions.compact_assets")} + +
+
+ ); +} diff --git a/apps/web/components/admin/AdminCard.tsx b/apps/web/components/admin/AdminCard.tsx new file mode 100644 index 00000000..3a52b5e5 --- /dev/null +++ b/apps/web/components/admin/AdminCard.tsx @@ -0,0 +1,3 @@ +export function AdminCard({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/apps/web/components/admin/AdminNotices.tsx b/apps/web/components/admin/AdminNotices.tsx new file mode 100644 index 00000000..4977736f --- /dev/null +++ b/apps/web/components/admin/AdminNotices.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { api } from "@/lib/trpc"; +import { AlertCircle } from "lucide-react"; + +import { AdminCard } from "./AdminCard"; + +interface AdminNotice { + level: "info" | "warning" | "error"; + message: React.ReactNode; + title: string; +} + +function useAdminNotices() { + const { data } = api.admin.getAdminNoticies.useQuery(); + if (!data) { + return []; + } + const ret: AdminNotice[] = []; + if (data.legacyContainersNotice) { + ret.push({ + level: "warning", + message: ( +

+ You're using the legacy docker container images. Those will stop + getting supported soon. Please follow{" "} + + this guide + {" "} + to upgrade. +

+ ), + title: "Legacy Container Images", + }); + } + return ret; +} + +export function AdminNotices() { + const notices = useAdminNotices(); + + if (notices.length === 0) { + return null; + } + return ( + +
+ {notices.map((n, i) => ( + + + {n.title} + {n.message} + + ))} +
+
+ ); +} + +export function AdminNoticeBadge() { + const notices = useAdminNotices(); + if (notices.length === 0) { + return null; + } + return {notices.length}; +} diff --git a/apps/web/components/admin/ChangeRoleDialog.tsx b/apps/web/components/admin/ChangeRoleDialog.tsx new file mode 100644 index 00000000..26ad5dce --- /dev/null +++ b/apps/web/components/admin/ChangeRoleDialog.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { changeRoleSchema } from "@hoarder/shared/types/admin"; + +type ChangeRoleSchema = z.infer; + +interface ChangeRoleDialogProps { + userId: string; + currentRole: "user" | "admin"; + children?: React.ReactNode; +} + +export default function ChangeRoleDialog({ + userId, + currentRole, + children, +}: ChangeRoleDialogProps) { + const apiUtils = api.useUtils(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm({ + resolver: zodResolver(changeRoleSchema), + defaultValues: { + userId, + role: currentRole, + }, + }); + const { mutate, isPending } = api.admin.changeRole.useMutation({ + onSuccess: () => { + toast({ + description: "Role changed successfully", + }); + apiUtils.users.list.invalidate(); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to change role", + }); + } + }, + }); + + useEffect(() => { + if (isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + + {children} + + + + Change Role + +
+ mutate(val))}> +
+ ( + + Role + + + + + + )} + /> + ( + + + + + + )} + /> + + + + + + Change + + +
+
+ +
+
+ ); +} diff --git a/apps/web/components/admin/ResetPasswordDialog.tsx b/apps/web/components/admin/ResetPasswordDialog.tsx new file mode 100644 index 00000000..32183d1a --- /dev/null +++ b/apps/web/components/admin/ResetPasswordDialog.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; // Adjust the import path as needed +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { resetPasswordSchema } from "@hoarder/shared/types/admin"; + +interface ResetPasswordDialogProps { + userId: string; + children?: React.ReactNode; +} + +type ResetPasswordSchema = z.infer; + +export default function ResetPasswordDialog({ + children, + userId, +}: ResetPasswordDialogProps) { + const [isOpen, onOpenChange] = useState(false); + const form = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + userId, + newPassword: "", + newPasswordConfirm: "", + }, + }); + const { mutate, isPending } = api.admin.resetPassword.useMutation({ + onSuccess: () => { + toast({ + description: "Password reset successfully", + }); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to reset password", + }); + } + }, + }); + + useEffect(() => { + if (isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + + {children} + + + Reset Password + +
+ mutate(val))}> +
+ ( + + New Password + + + + + + )} + /> + ( + + Confirm New Password + + + + + + )} + /> + + + + + + Reset + + +
+
+ +
+
+ ); +} diff --git a/apps/web/components/admin/ServerStats.tsx b/apps/web/components/admin/ServerStats.tsx new file mode 100644 index 00000000..1f0c7e9d --- /dev/null +++ b/apps/web/components/admin/ServerStats.tsx @@ -0,0 +1,148 @@ +"use client"; + +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; +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"; + +function useLatestRelease() { + const { data } = useQuery({ + queryKey: ["latest-release"], + queryFn: async () => { + const res = await fetch(REPO_LATEST_RELEASE_API); + if (!res.ok) { + return undefined; + } + const data = (await res.json()) as { name: string }; + return data.name; + }, + staleTime: 60 * 60 * 1000, + enabled: !useClientConfig().disableNewReleaseCheck, + }); + return data; +} + +function ReleaseInfo() { + const currentRelease = useClientConfig().serverVersion ?? "NA"; + const latestRelease = useLatestRelease(); + + let newRelease; + if (latestRelease && currentRelease != latestRelease) { + newRelease = ( + + ({latestRelease} ⬆️) + + ); + } + return ( +
+ {currentRelease} + {newRelease} +
+ ); +} + +export default function ServerStats() { + const { t } = useTranslation(); + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + if (!serverStats) { + return ; + } + + return ( +
+
+ {t("admin.server_stats.server_stats")} +
+
+
+
+ {t("admin.server_stats.total_users")} +
+
{serverStats.numUsers}
+
+
+
+ {t("admin.server_stats.total_bookmarks")} +
+
+ {serverStats.numBookmarks} +
+
+
+
+ {t("admin.server_stats.server_version")} +
+ +
+
+ +
+
+ {t("admin.background_jobs.background_jobs")} +
+ + + {t("admin.background_jobs.job")} + {t("admin.background_jobs.queued")} + {t("admin.background_jobs.pending")} + {t("admin.background_jobs.failed")} + + + + + {t("admin.background_jobs.crawler_jobs")} + + {serverStats.crawlStats.queued} + {serverStats.crawlStats.pending} + {serverStats.crawlStats.failed} + + + {t("admin.background_jobs.indexing_jobs")} + {serverStats.indexingStats.queued} + - + - + + + {t("admin.background_jobs.inference_jobs")} + {serverStats.inferenceStats.queued} + {serverStats.inferenceStats.pending} + {serverStats.inferenceStats.failed} + + + + {t("admin.background_jobs.tidy_assets_jobs")} + + {serverStats.tidyAssetsStats.queued} + - + - + + +
+
+
+ ); +} diff --git a/apps/web/components/admin/UserList.tsx b/apps/web/components/admin/UserList.tsx new file mode 100644 index 00000000..3dfcaad1 --- /dev/null +++ b/apps/web/components/admin/UserList.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { ActionButtonWithTooltip } from "@/components/ui/action-button"; +import { ButtonWithTooltip } from "@/components/ui/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 { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { Check, KeyRound, Pencil, Trash, UserPlus, X } from "lucide-react"; +import { useSession } from "next-auth/react"; + +import AddUserDialog from "./AddUserDialog"; +import ChangeRoleDialog from "./ChangeRoleDialog"; +import ResetPasswordDialog from "./ResetPasswordDialog"; + +function toHumanReadableSize(size: number) { + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + if (size === 0) return "0 Bytes"; + const i = Math.floor(Math.log(size) / Math.log(1024)); + return (size / Math.pow(1024, i)).toFixed(2) + " " + sizes[i]; +} + +export default function UsersSection() { + const { t } = useTranslation(); + const { data: session } = useSession(); + const invalidateUserList = api.useUtils().users.list.invalidate; + const { data: users } = api.users.list.useQuery(); + const { data: userStats } = api.admin.userStats.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 || !userStats) { + return ; + } + + return ( +
+
+ {t("admin.users_list.users_list")} + + + + + +
+ + + + {t("common.name")} + {t("common.email")} + {t("admin.users_list.num_bookmarks")} + {t("admin.users_list.asset_sizes")} + {t("common.role")} + {t("admin.users_list.local_user")} + {t("common.actions")} + + + {users.users.map((u) => ( + + {u.name} + {u.email} + + {userStats[u.id].numBookmarks} + + + {toHumanReadableSize(userStats[u.id].assetSizes)} + + + {u.role && t(`common.roles.${u.role}`)} + + + {u.localUser ? : } + + + deleteUser({ userId: u.id })} + loading={isDeletionPending} + disabled={session!.user.id == u.id} + > + + + + + + + + + + + + + + + ))} + +
+
+ ); +} diff --git a/apps/web/components/admin/sidebar/MobileSidebar.tsx b/apps/web/components/admin/sidebar/MobileSidebar.tsx new file mode 100644 index 00000000..416b944c --- /dev/null +++ b/apps/web/components/admin/sidebar/MobileSidebar.tsx @@ -0,0 +1,21 @@ +import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; +import { useTranslation } from "@/lib/i18n/server"; + +import { adminSidebarItems } from "./items"; + +export default async function MobileSidebar() { + const { t } = await useTranslation(); + return ( + + ); +} diff --git a/apps/web/components/admin/sidebar/Sidebar.tsx b/apps/web/components/admin/sidebar/Sidebar.tsx new file mode 100644 index 00000000..8a5d615a --- /dev/null +++ b/apps/web/components/admin/sidebar/Sidebar.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import SidebarItem from "@/components/shared/sidebar/SidebarItem"; +import { useTranslation } from "@/lib/i18n/server"; +import { getServerAuthSession } from "@/server/auth"; + +import serverConfig from "@hoarder/shared/config"; + +import { adminSidebarItems } from "./items"; + +export default async function Sidebar() { + const { t } = await useTranslation(); + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + return ( + + ); +} diff --git a/apps/web/components/admin/sidebar/items.tsx b/apps/web/components/admin/sidebar/items.tsx new file mode 100644 index 00000000..78dfee34 --- /dev/null +++ b/apps/web/components/admin/sidebar/items.tsx @@ -0,0 +1,31 @@ +import { TFunction } from "i18next"; +import { Activity, ArrowLeft, Settings, Users } from "lucide-react"; + +export const adminSidebarItems = ( + t: TFunction, +): { + name: string; + icon: JSX.Element; + path: string; +}[] => [ + { + name: t("settings.back_to_app"), + icon: , + path: "/dashboard/bookmarks", + }, + { + name: t("admin.server_stats.server_stats"), + icon: , + path: "/admin/overview", + }, + { + name: t("admin.users_list.users_list"), + icon: , + path: "/admin/users", + }, + { + name: t("common.actions"), + icon: , + path: "/admin/actions", + }, +]; diff --git a/apps/web/components/dashboard/admin/AddUserDialog.tsx b/apps/web/components/dashboard/admin/AddUserDialog.tsx deleted file mode 100644 index a13c6b88..00000000 --- a/apps/web/components/dashboard/admin/AddUserDialog.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useEffect, useState } from "react"; -import { ActionButton } from "@/components/ui/action-button"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { TRPCClientError } from "@trpc/client"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { zAdminCreateUserSchema } from "@hoarder/shared/types/admin"; - -type AdminCreateUserSchema = z.infer; - -export default function AddUserDialog({ - children, -}: { - children?: React.ReactNode; -}) { - const apiUtils = api.useUtils(); - const [isOpen, onOpenChange] = useState(false); - const form = useForm({ - resolver: zodResolver(zAdminCreateUserSchema), - defaultValues: { - name: "", - email: "", - password: "", - confirmPassword: "", - role: "user", - }, - }); - const { mutate, isPending } = api.admin.createUser.useMutation({ - onSuccess: () => { - toast({ - description: "User created successfully", - }); - onOpenChange(false); - apiUtils.users.list.invalidate(); - apiUtils.admin.userStats.invalidate(); - }, - onError: (error) => { - if (error instanceof TRPCClientError) { - toast({ - variant: "destructive", - description: error.message, - }); - } else { - toast({ - variant: "destructive", - description: "Failed to create user", - }); - } - }, - }); - - useEffect(() => { - if (!isOpen) { - form.reset(); - } - }, [isOpen, form]); - - return ( - - {children} - - - Add User - -
- mutate(val))}> -
- ( - - Name - - - - - - )} - /> - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - )} - /> - ( - - Confirm Password - - - - - - )} - /> - ( - - Role - - - - - - )} - /> - - - - - - Create - - -
-
- -
-
- ); -} diff --git a/apps/web/components/dashboard/admin/AdminActions.tsx b/apps/web/components/dashboard/admin/AdminActions.tsx deleted file mode 100644 index 3b95045c..00000000 --- a/apps/web/components/dashboard/admin/AdminActions.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; - -import { ActionButton } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; -import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; - -export default function AdminActions() { - const { t } = useTranslation(); - 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 ( -
-
{t("common.actions")}
-
- - recrawlLinks({ crawlStatus: "failure", runInference: true }) - } - > - {t("admin.actions.recrawl_failed_links_only")} - - - recrawlLinks({ crawlStatus: "all", runInference: true }) - } - > - {t("admin.actions.recrawl_all_links")} - - - recrawlLinks({ crawlStatus: "all", runInference: false }) - } - > - {t("admin.actions.recrawl_all_links")} ( - {t("admin.actions.without_inference")}) - - - reRunInferenceOnAllBookmarks({ taggingStatus: "failure" }) - } - > - {t("admin.actions.regenerate_ai_tags_for_failed_bookmarks_only")} - - reRunInferenceOnAllBookmarks({ taggingStatus: "all" })} - > - {t("admin.actions.regenerate_ai_tags_for_all_bookmarks")} - - reindexBookmarks()} - > - {t("admin.actions.reindex_all_bookmarks")} - - tidyAssets()} - > - {t("admin.actions.compact_assets")} - -
-
- ); -} diff --git a/apps/web/components/dashboard/admin/AdminCard.tsx b/apps/web/components/dashboard/admin/AdminCard.tsx deleted file mode 100644 index 3a52b5e5..00000000 --- a/apps/web/components/dashboard/admin/AdminCard.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function AdminCard({ children }: { children: React.ReactNode }) { - return
{children}
; -} diff --git a/apps/web/components/dashboard/admin/AdminNotices.tsx b/apps/web/components/dashboard/admin/AdminNotices.tsx deleted file mode 100644 index 4977736f..00000000 --- a/apps/web/components/dashboard/admin/AdminNotices.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { api } from "@/lib/trpc"; -import { AlertCircle } from "lucide-react"; - -import { AdminCard } from "./AdminCard"; - -interface AdminNotice { - level: "info" | "warning" | "error"; - message: React.ReactNode; - title: string; -} - -function useAdminNotices() { - const { data } = api.admin.getAdminNoticies.useQuery(); - if (!data) { - return []; - } - const ret: AdminNotice[] = []; - if (data.legacyContainersNotice) { - ret.push({ - level: "warning", - message: ( -

- You're using the legacy docker container images. Those will stop - getting supported soon. Please follow{" "} - - this guide - {" "} - to upgrade. -

- ), - title: "Legacy Container Images", - }); - } - return ret; -} - -export function AdminNotices() { - const notices = useAdminNotices(); - - if (notices.length === 0) { - return null; - } - return ( - -
- {notices.map((n, i) => ( - - - {n.title} - {n.message} - - ))} -
-
- ); -} - -export function AdminNoticeBadge() { - const notices = useAdminNotices(); - if (notices.length === 0) { - return null; - } - return {notices.length}; -} diff --git a/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx b/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx deleted file mode 100644 index 26ad5dce..00000000 --- a/apps/web/components/dashboard/admin/ChangeRoleDialog.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useEffect, useState } from "react"; -import { ActionButton } from "@/components/ui/action-button"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { TRPCClientError } from "@trpc/client"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { changeRoleSchema } from "@hoarder/shared/types/admin"; - -type ChangeRoleSchema = z.infer; - -interface ChangeRoleDialogProps { - userId: string; - currentRole: "user" | "admin"; - children?: React.ReactNode; -} - -export default function ChangeRoleDialog({ - userId, - currentRole, - children, -}: ChangeRoleDialogProps) { - const apiUtils = api.useUtils(); - const [isOpen, onOpenChange] = useState(false); - const form = useForm({ - resolver: zodResolver(changeRoleSchema), - defaultValues: { - userId, - role: currentRole, - }, - }); - const { mutate, isPending } = api.admin.changeRole.useMutation({ - onSuccess: () => { - toast({ - description: "Role changed successfully", - }); - apiUtils.users.list.invalidate(); - onOpenChange(false); - }, - onError: (error) => { - if (error instanceof TRPCClientError) { - toast({ - variant: "destructive", - description: error.message, - }); - } else { - toast({ - variant: "destructive", - description: "Failed to change role", - }); - } - }, - }); - - useEffect(() => { - if (isOpen) { - form.reset(); - } - }, [isOpen, form]); - - return ( - - {children} - - - - Change Role - -
- mutate(val))}> -
- ( - - Role - - - - - - )} - /> - ( - - - - - - )} - /> - - - - - - Change - - -
-
- -
-
- ); -} diff --git a/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx b/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx deleted file mode 100644 index 32183d1a..00000000 --- a/apps/web/components/dashboard/admin/ResetPasswordDialog.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useEffect, useState } from "react"; -import { ActionButton } from "@/components/ui/action-button"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; // Adjust the import path as needed -import { zodResolver } from "@hookform/resolvers/zod"; -import { TRPCClientError } from "@trpc/client"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { resetPasswordSchema } from "@hoarder/shared/types/admin"; - -interface ResetPasswordDialogProps { - userId: string; - children?: React.ReactNode; -} - -type ResetPasswordSchema = z.infer; - -export default function ResetPasswordDialog({ - children, - userId, -}: ResetPasswordDialogProps) { - const [isOpen, onOpenChange] = useState(false); - const form = useForm({ - resolver: zodResolver(resetPasswordSchema), - defaultValues: { - userId, - newPassword: "", - newPasswordConfirm: "", - }, - }); - const { mutate, isPending } = api.admin.resetPassword.useMutation({ - onSuccess: () => { - toast({ - description: "Password reset successfully", - }); - onOpenChange(false); - }, - onError: (error) => { - if (error instanceof TRPCClientError) { - toast({ - variant: "destructive", - description: error.message, - }); - } else { - toast({ - variant: "destructive", - description: "Failed to reset password", - }); - } - }, - }); - - useEffect(() => { - if (isOpen) { - form.reset(); - } - }, [isOpen, form]); - - return ( - - {children} - - - Reset Password - -
- mutate(val))}> -
- ( - - New Password - - - - - - )} - /> - ( - - Confirm New Password - - - - - - )} - /> - - - - - - Reset - - -
-
- -
-
- ); -} diff --git a/apps/web/components/dashboard/admin/ServerStats.tsx b/apps/web/components/dashboard/admin/ServerStats.tsx deleted file mode 100644 index da69390b..00000000 --- a/apps/web/components/dashboard/admin/ServerStats.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import LoadingSpinner from "@/components/ui/spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { useClientConfig } from "@/lib/clientConfig"; -import { useTranslation } from "@/lib/i18n/client"; -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"; - -function useLatestRelease() { - const { data } = useQuery({ - queryKey: ["latest-release"], - queryFn: async () => { - const res = await fetch(REPO_LATEST_RELEASE_API); - if (!res.ok) { - return undefined; - } - const data = (await res.json()) as { name: string }; - return data.name; - }, - staleTime: 60 * 60 * 1000, - enabled: !useClientConfig().disableNewReleaseCheck, - }); - return data; -} - -function ReleaseInfo() { - const currentRelease = useClientConfig().serverVersion ?? "NA"; - const latestRelease = useLatestRelease(); - - let newRelease; - if (latestRelease && currentRelease != latestRelease) { - newRelease = ( - - ({latestRelease} ⬆️) - - ); - } - return ( -
- {currentRelease} - {newRelease} -
- ); -} - -export default function ServerStats() { - const { t } = useTranslation(); - const { data: serverStats } = api.admin.stats.useQuery(undefined, { - refetchInterval: 1000, - placeholderData: keepPreviousData, - }); - - if (!serverStats) { - return ; - } - - return ( - <> -
- {t("admin.server_stats.server_stats")} -
-
-
-
- {t("admin.server_stats.total_users")} -
-
{serverStats.numUsers}
-
-
-
- {t("admin.server_stats.total_bookmarks")} -
-
- {serverStats.numBookmarks} -
-
-
-
- {t("admin.server_stats.server_version")} -
- -
-
- -
-
- {t("admin.background_jobs.background_jobs")} -
- - - {t("admin.background_jobs.job")} - {t("admin.background_jobs.queued")} - {t("admin.background_jobs.pending")} - {t("admin.background_jobs.failed")} - - - - - {t("admin.background_jobs.crawler_jobs")} - - {serverStats.crawlStats.queued} - {serverStats.crawlStats.pending} - {serverStats.crawlStats.failed} - - - {t("admin.background_jobs.indexing_jobs")} - {serverStats.indexingStats.queued} - - - - - - - {t("admin.background_jobs.inference_jobs")} - {serverStats.inferenceStats.queued} - {serverStats.inferenceStats.pending} - {serverStats.inferenceStats.failed} - - - - {t("admin.background_jobs.tidy_assets_jobs")} - - {serverStats.tidyAssetsStats.queued} - - - - - - -
-
- - ); -} diff --git a/apps/web/components/dashboard/admin/UserList.tsx b/apps/web/components/dashboard/admin/UserList.tsx deleted file mode 100644 index 8c788ef4..00000000 --- a/apps/web/components/dashboard/admin/UserList.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { ActionButtonWithTooltip } from "@/components/ui/action-button"; -import { ButtonWithTooltip } from "@/components/ui/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 { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; -import { Check, KeyRound, Pencil, Trash, UserPlus, X } from "lucide-react"; -import { useSession } from "next-auth/react"; - -import AddUserDialog from "./AddUserDialog"; -import ChangeRoleDialog from "./ChangeRoleDialog"; -import ResetPasswordDialog from "./ResetPasswordDialog"; - -function toHumanReadableSize(size: number) { - const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; - if (size === 0) return "0 Bytes"; - const i = Math.floor(Math.log(size) / Math.log(1024)); - return (size / Math.pow(1024, i)).toFixed(2) + " " + sizes[i]; -} - -export default function UsersSection() { - const { t } = useTranslation(); - const { data: session } = useSession(); - const invalidateUserList = api.useUtils().users.list.invalidate; - const { data: users } = api.users.list.useQuery(); - const { data: userStats } = api.admin.userStats.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 || !userStats) { - return ; - } - - return ( - <> -
- {t("admin.users_list.users_list")} - - - - - -
- - - - {t("common.name")} - {t("common.email")} - {t("admin.users_list.num_bookmarks")} - {t("admin.users_list.asset_sizes")} - {t("common.role")} - {t("admin.users_list.local_user")} - {t("common.actions")} - - - {users.users.map((u) => ( - - {u.name} - {u.email} - - {userStats[u.id].numBookmarks} - - - {toHumanReadableSize(userStats[u.id].assetSizes)} - - - {u.role && t(`common.roles.${u.role}`)} - - - {u.localUser ? : } - - - deleteUser({ userId: u.id })} - loading={isDeletionPending} - disabled={session!.user.id == u.id} - > - - - - - - - - - - - - - - - ))} - -
- - ); -} diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index fc18e9d2..3d125606 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -16,7 +16,7 @@ import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import { useTheme } from "next-themes"; -import { AdminNoticeBadge } from "../admin/AdminNotices"; +import { AdminNoticeBadge } from "../../admin/AdminNotices"; function DarkModeToggle() { const { t } = useTranslation(); @@ -74,7 +74,7 @@ export default function SidebarProfileOptions() { {session.user.role == "admin" && ( - +
{t("admin.admin_settings")} -- cgit v1.2.3-70-g09d2