mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-27 22:25:58 +02:00
add server admin panel to delete users
This commit is contained in:
parent
dbfc8b51aa
commit
0dcfeb3587
10 changed files with 439 additions and 18 deletions
|
@ -245,13 +245,9 @@ export class Config {
|
||||||
: "false";
|
: "false";
|
||||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||||
|
|
||||||
this.checkSupporterKey()
|
this.checkSupporterKey().catch((error) => {
|
||||||
.then(() => {
|
console.error("Error checking supporter key:", error);
|
||||||
console.log("Supporter key checked");
|
});
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error checking supporter key:", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.rawConfig = parsedConfig.data;
|
this.rawConfig = parsedConfig.data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ export async function verifyUserIsServerAdmin(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!req.user?.serverAdmin) {
|
if (!req.user?.serverAdmin) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -24,7 +24,7 @@ export async function verifyUserIsServerAdmin(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -31,6 +31,7 @@ async function queryUsers(limit: number, offset: number) {
|
||||||
id: users.userId,
|
id: users.userId,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
dateCreated: users.dateCreated,
|
dateCreated: users.dateCreated,
|
||||||
|
serverAdmin: users.serverAdmin
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.serverAdmin, false))
|
.where(eq(users.serverAdmin, false))
|
||||||
|
@ -60,10 +61,7 @@ export async function adminListUsers(
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
const allUsers = await queryUsers(
|
const allUsers = await queryUsers(limit, offset);
|
||||||
limit,
|
|
||||||
offset
|
|
||||||
);
|
|
||||||
|
|
||||||
const [{ count }] = await db
|
const [{ count }] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { userOrgs, users } from "@server/db/schema";
|
import { users } from "@server/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -36,13 +36,22 @@ export async function adminRemoveUser(
|
||||||
// get the user first
|
// get the user first
|
||||||
const user = await db
|
const user = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(users)
|
||||||
.where(eq(userOrgs.userId, userId));
|
.where(eq(users.userId, userId));
|
||||||
|
|
||||||
if (!user || user.length === 0) {
|
if (!user || user.length === 0) {
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
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));
|
await db.delete(users).where(eq(users.userId, userId));
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|
|
@ -6,4 +6,4 @@ export * from "./inviteUser";
|
||||||
export * from "./acceptInvite";
|
export * from "./acceptInvite";
|
||||||
export * from "./getOrgUser";
|
export * from "./getOrgUser";
|
||||||
export * from "./adminListUsers";
|
export * from "./adminListUsers";
|
||||||
export * from "./adminRemoveUser";
|
export * from "./adminRemoveUser";
|
||||||
|
|
71
src/app/admin/layout.tsx
Normal file
71
src/app/admin/layout.tsx
Normal 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
11
src/app/admin/page.tsx
Normal 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 <></>;
|
||||||
|
}
|
141
src/app/admin/users/AdminUsersDataTable.tsx
Normal file
141
src/app/admin/users/AdminUsersDataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
151
src/app/admin/users/AdminUsersTable.tsx
Normal file
151
src/app/admin/users/AdminUsersTable.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
44
src/app/admin/users/page.tsx
Normal file
44
src/app/admin/users/page.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue