From 0dcfeb35879607cc4a48949435db03c1afec238d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 21 Mar 2025 18:04:14 -0400 Subject: [PATCH] add server admin panel to delete users --- server/lib/config.ts | 10 +- server/middlewares/verifyUserIsServerAdmin.ts | 4 +- server/routers/user/adminListUsers.ts | 6 +- server/routers/user/adminRemoveUser.ts | 17 +- server/routers/user/index.ts | 2 +- src/app/admin/layout.tsx | 71 ++++++++ src/app/admin/page.tsx | 11 ++ src/app/admin/users/AdminUsersDataTable.tsx | 141 ++++++++++++++++ src/app/admin/users/AdminUsersTable.tsx | 151 ++++++++++++++++++ src/app/admin/users/page.tsx | 44 +++++ 10 files changed, 439 insertions(+), 18 deletions(-) create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/users/AdminUsersDataTable.tsx create mode 100644 src/app/admin/users/AdminUsersTable.tsx create mode 100644 src/app/admin/users/page.tsx diff --git a/server/lib/config.ts b/server/lib/config.ts index 42d442ab..83fbc60b 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -245,13 +245,9 @@ export class Config { : "false"; process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; - this.checkSupporterKey() - .then(() => { - console.log("Supporter key checked"); - }) - .catch((error) => { - console.error("Error checking supporter key:", error); - }); + this.checkSupporterKey().catch((error) => { + console.error("Error checking supporter key:", error); + }); this.rawConfig = parsedConfig.data; } diff --git a/server/middlewares/verifyUserIsServerAdmin.ts b/server/middlewares/verifyUserIsServerAdmin.ts index 9088a425..9598bd9b 100644 --- a/server/middlewares/verifyUserIsServerAdmin.ts +++ b/server/middlewares/verifyUserIsServerAdmin.ts @@ -14,7 +14,7 @@ export async function verifyUserIsServerAdmin( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } - + try { if (!req.user?.serverAdmin) { return next( @@ -24,7 +24,7 @@ export async function verifyUserIsServerAdmin( ) ); } - + return next(); } catch (e) { return next( diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index e4d5c206..19233741 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -31,6 +31,7 @@ async function queryUsers(limit: number, offset: number) { id: users.userId, email: users.email, dateCreated: users.dateCreated, + serverAdmin: users.serverAdmin }) .from(users) .where(eq(users.serverAdmin, false)) @@ -60,10 +61,7 @@ export async function adminListUsers( } const { limit, offset } = parsedQuery.data; - const allUsers = await queryUsers( - limit, - offset - ); + const allUsers = await queryUsers(limit, offset); const [{ count }] = await db .select({ count: sql`count(*)` }) diff --git a/server/routers/user/adminRemoveUser.ts b/server/routers/user/adminRemoveUser.ts index a8128900..164b3753 100644 --- a/server/routers/user/adminRemoveUser.ts +++ b/server/routers/user/adminRemoveUser.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userOrgs, users } from "@server/db/schema"; -import { and, eq } from "drizzle-orm"; +import { users } from "@server/db/schema"; +import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -36,13 +36,22 @@ export async function adminRemoveUser( // get the user first const user = await db .select() - .from(userOrgs) - .where(eq(userOrgs.userId, userId)); + .from(users) + .where(eq(users.userId, userId)); if (!user || user.length === 0) { return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); } + if (user[0].serverAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot remove server admin" + ) + ); + } + await db.delete(users).where(eq(users.userId, userId)); return response(res, { diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 22e06c58..5311fc93 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -6,4 +6,4 @@ export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; export * from "./adminListUsers"; -export * from "./adminRemoveUser"; \ No newline at end of file +export * from "./adminRemoveUser"; diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 00000000..f3e55a3f --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,71 @@ +import { Metadata } from "next"; +import { TopbarNav } from "@app/components/TopbarNav"; +import { Users } from "lucide-react"; +import { Header } from "@app/components/Header"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import UserProvider from "@app/providers/UserProvider"; +import { ListOrgsResponse } from "@server/routers/org"; +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { authCookieHeader } from "@app/lib/api/cookies"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: `Server Admin - Pangolin`, + description: "" +}; + +const topNavItems = [ + { + title: "All Users", + href: "/admin/users", + icon: + } +]; + +interface LayoutProps { + children: React.ReactNode; +} + +export default async function SettingsLayout(props: LayoutProps) { + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user || !user.serverAdmin) { + redirect(`/`); + } + + const cookie = await authCookieHeader(); + let orgs: ListOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(() => + internal.get>(`/orgs`, cookie) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + + return ( + <> +
+
+
+ +
+ +
+ +
+
+ +
+ {props.children} +
+ + ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 00000000..c75940f7 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,11 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { cache } from "react"; +import { redirect } from "next/navigation"; + +type AdminPageProps = {}; + +export default async function OrgPage(props: AdminPageProps) { + redirect(`/admin/users`); + + return <>; +} diff --git a/src/app/admin/users/AdminUsersDataTable.tsx b/src/app/admin/users/AdminUsersDataTable.tsx new file mode 100644 index 00000000..b125c539 --- /dev/null +++ b/src/app/admin/users/AdminUsersDataTable.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { useState } from "react"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Search } from "lucide-react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function UsersDataTable({ + columns, + data +}: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + initialState: { + pagination: { + pageSize: 20, + pageIndex: 0 + } + }, + state: { + sorting, + columnFilters + } + }); + + return ( +
+
+
+ + table + .getColumn("name") + ?.setFilterValue(event.target.value) + } + className="w-full pl-8" + /> + +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + This server has no users. + + + )} + +
+
+
+ +
+
+ ); +} diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx new file mode 100644 index 00000000..0ead375d --- /dev/null +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { UsersDataTable } from "./AdminUsersDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; + +export type GlobalUserRow = { + id: string; + email: string; + dateCreated: string; +}; + +type Props = { + users: GlobalUserRow[]; +}; + +export default function UsersTable({ users }: Props) { + const router = useRouter(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(users); + + const api = createApiClient(useEnvContext()); + + const deleteUser = (id: string) => { + api.delete(`/user/${id}`) + .catch((e) => { + console.error("Error deleting user", e); + toast({ + variant: "destructive", + title: "Error deleting user", + description: formatAxiosError(e, "Error deleting user") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== id); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "email", + header: ({ column }) => { + return ( + + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( + <> +
+ +
+ + ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

+ Are you sure you want to permanently delete{" "} + {selected?.email || selected?.id} from + the server? +

+ +

+ + The user will be removed from all + organizations and be completely removed from + the server. + +

+ +

+ To confirm, please type the email of the user + below. +

+
+ } + buttonText="Confirm Delete User" + onConfirm={async () => deleteUser(selected!.id)} + string={selected.email} + title="Delete User from Server" + /> + )} + + + + ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 00000000..a8ab19a2 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,44 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; +import UsersTable, { GlobalUserRow } from "./AdminUsersTable"; + +type PageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function UsersPage(props: PageProps) { + let rows: AdminListUsersResponse["users"] = []; + try { + const res = await internal.get>( + `/users`, + await authCookieHeader() + ); + rows = res.data.data.users; + } catch (e) { + console.error(e); + } + + const userRows: GlobalUserRow[] = rows.map((row) => { + return { + id: row.id, + email: row.email, + dateCreated: row.dateCreated, + serverAdmin: row.serverAdmin + }; + }); + + return ( + <> + + + + ); +}