add server admin panel to delete users

This commit is contained in:
miloschwartz 2025-03-21 18:04:14 -04:00
parent dbfc8b51aa
commit 0dcfeb3587
No known key found for this signature in database
10 changed files with 439 additions and 18 deletions

View file

@ -245,11 +245,7 @@ export class Config {
: "false";
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
this.checkSupporterKey()
.then(() => {
console.log("Supporter key checked");
})
.catch((error) => {
this.checkSupporterKey().catch((error) => {
console.error("Error checking supporter key:", error);
});

View file

@ -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<number>`count(*)` })

View file

@ -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, {

71
src/app/admin/layout.tsx Normal file
View file

@ -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: <Users className="h-4 w-4" />
}
];
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<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {}
return (
<>
<div className="w-full bg-card sm:px-0 fixed top-0 z-10 border-b">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4 px-3 md:px-0">
<UserProvider user={user}>
<Header orgId={""} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
{props.children}
</div>
</>
);
}

11
src/app/admin/page.tsx Normal file
View file

@ -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 <></>;
}

View file

@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function UsersDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
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 (
<div>
<div className="flex items-center justify-between pb-4">
<div className="flex items-center max-w-sm mr-2 w-full relative">
<Input
placeholder="Search server users"
value={
(table
.getColumn("email")
?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table
.getColumn("name")
?.setFilterValue(event.target.value)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
</div>
</div>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && "selected"
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
This server has no users.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>
</div>
);
}

View file

@ -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<GlobalUserRow | null>(null);
const [rows, setRows] = useState<GlobalUserRow[]>(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<GlobalUserRow>[] = [
{
accessorKey: "id",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
</Button>
);
}
},
{
accessorKey: "email",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center justify-end">
<Button
variant={"outlinePrimary"}
className="ml-2"
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
Delete
</Button>
</div>
</>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to permanently delete{" "}
<b>{selected?.email || selected?.id}</b> from
the server?
</p>
<p>
<b>
The user will be removed from all
organizations and be completely removed from
the server.
</b>
</p>
<p>
To confirm, please type the email of the user
below.
</p>
</div>
}
buttonText="Confirm Delete User"
onConfirm={async () => deleteUser(selected!.id)}
string={selected.email}
title="Delete User from Server"
/>
)}
<UsersDataTable columns={columns} data={rows} />
</>
);
}

View file

@ -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<AxiosResponse<AdminListUsersResponse>>(
`/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 (
<>
<SettingsSectionTitle
title="Manage All Users"
description="View and manage all users in the system"
/>
<UsersTable users={userRows} />
</>
);
}