diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index fe240226..b83b5546 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,31 +1,69 @@ -import { Request, Response, NextFunction } from 'express'; -import { z } from 'zod'; -import { db } from '@server/db'; -import { roles, userOrgs, users } from '@server/db/schema'; +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { roles, userOrgs, users } from "@server/db/schema"; import response from "@server/utils/response"; -import HttpCode from '@server/types/HttpCode'; -import createHttpError from 'http-errors'; -import { sql } from 'drizzle-orm'; -import { ActionsEnum, checkUserActionPermission } from '@server/auth/actions'; -import logger from '@server/logger'; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { sql } from "drizzle-orm"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import logger from "@server/logger"; const listUsersParamsSchema = z.object({ - orgId: z.string().optional().transform(Number).pipe(z.number().int().positive()), + orgId: z.string(), }); const listUsersSchema = z.object({ - limit: z.string().optional().transform(Number).pipe(z.number().int().positive().default(10)), - offset: z.string().optional().transform(Number).pipe(z.number().int().nonnegative().default(0)), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()), }); -export async function listUsers(req: Request, res: Response, next: NextFunction): Promise { +async function queryUsers(orgId: string, limit: number, offset: number) { + return await db + .select({ + id: users.userId, + email: users.email, + emailVerified: users.emailVerified, + dateCreated: users.dateCreated, + orgId: userOrgs.orgId, + roleId: userOrgs.roleId, + roleName: roles.name, + }) + .from(users) + .leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`) + .leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`) + .where(sql`${userOrgs.orgId} = ${orgId}`) + .limit(limit) + .offset(offset); +} + +export type ListUsersResponse = { + users: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { try { const parsedQuery = listUsersSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - parsedQuery.error.errors.map(e => e.message).join(', ') + parsedQuery.error.errors.map((e) => e.message).join(", ") ) ); } @@ -36,7 +74,7 @@ export async function listUsers(req: Request, res: Response, next: NextFunction) return next( createHttpError( HttpCode.BAD_REQUEST, - parsedParams.error.errors.map(e => e.message).join(', ') + parsedParams.error.errors.map((e) => e.message).join(", ") ) ); } @@ -44,35 +82,31 @@ export async function listUsers(req: Request, res: Response, next: NextFunction) const { orgId } = parsedParams.data; // Check if the user has permission to list users - const hasPermission = await checkUserActionPermission(ActionsEnum.listUsers, req); + const hasPermission = await checkUserActionPermission( + ActionsEnum.listUsers, + req + ); if (!hasPermission) { - return next(createHttpError(HttpCode.FORBIDDEN, 'User does not have permission to perform this action')); + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to perform this action" + ) + ); } - // Query to join users, userOrgs, and roles tables - const usersWithRoles = await db - .select({ - id: users.userId, - email: users.email, - emailVerified: users.emailVerified, - dateCreated: users.dateCreated, - orgId: userOrgs.orgId, - roleId: userOrgs.roleId, - roleName: roles.name, - }) - .from(users) - .leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`) - .leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`) - .where(sql`${userOrgs.orgId} = ${orgId}`) - .limit(limit) - .offset(offset); + const usersWithRoles = await queryUsers( + orgId.toString(), + limit, + offset + ); // Count total users const [{ count }] = await db .select({ count: sql`count(*)` }) .from(users); - return response(res, { + return response(res, { data: { users: usersWithRoles, pagination: { @@ -88,6 +122,8 @@ export async function listUsers(req: Request, res: Response, next: NextFunction) }); } catch (error) { logger.error(error); - return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred...")); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); } } diff --git a/src/app/[orgId]/settings/resources/components/ResourcesDataTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesDataTable.tsx index 6e6bbf47..2416f0ac 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesDataTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesDataTable.tsx @@ -59,7 +59,7 @@ export function ResourcesDataTable({
({
({ : flexRender( header.column.columnDef .header, - header.getContext(), + header.getContext() )} ); @@ -115,7 +115,7 @@ export function SitesDataTable({ {flexRender( cell.column.columnDef.cell, - cell.getContext(), + cell.getContext() )} ))} diff --git a/src/app/[orgId]/settings/users/components/UsersDataTable.tsx b/src/app/[orgId]/settings/users/components/UsersDataTable.tsx new file mode 100644 index 00000000..9a9a69f5 --- /dev/null +++ b/src/app/[orgId]/settings/users/components/UsersDataTable.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useState } from "react"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "../../../../../components/DataTablePagination"; +import { Plus } from "lucide-react"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + inviteUser?: () => void; +} + +export function UsersDataTable({ + inviteUser, + 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(), + state: { + sorting, + columnFilters, + }, + }); + + return ( +
+
+ + table + .getColumn("email") + ?.setFilterValue(event.target.value) + } + className="max-w-sm mr-2" + /> + +
+
+ + + {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() + )} + + ))} + + )) + ) : ( + + + No Users. Invite one to share access to + resources. + + + )} + +
+
+
+ +
+
+ ); +} diff --git a/src/app/[orgId]/settings/users/components/UsersTable.tsx b/src/app/[orgId]/settings/users/components/UsersTable.tsx new file mode 100644 index 00000000..1276b5d7 --- /dev/null +++ b/src/app/[orgId]/settings/users/components/UsersTable.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { UsersDataTable } from "./UsersDataTable"; + +export type UserRow = { + id: string; + email: string; +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: "email", + header: ({ column }) => { + return ( + + ); + }, + }, + { + id: "actions", + cell: ({ row }) => { + const userRow = row.original; + + return ( + + + + + + Edit access + + + ); + }, + }, +]; + +type UsersTableProps = { + users: UserRow[]; +}; + +export default function UsersTable({ users }: UsersTableProps) { + return ( + { + console.log("Invite user"); + }} + /> + ); +} diff --git a/src/app/[orgId]/settings/users/page.tsx b/src/app/[orgId]/settings/users/page.tsx new file mode 100644 index 00000000..e3fa63aa --- /dev/null +++ b/src/app/[orgId]/settings/users/page.tsx @@ -0,0 +1,46 @@ +import { internal } from "@app/api"; +import { authCookieHeader } from "@app/api/cookies"; +import { ListUsersResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import UsersTable, { UserRow } from "./components/UsersTable"; + +type UsersPageProps = { + params: Promise<{ orgId: string }>; +}; + +export default async function UsersPage(props: UsersPageProps) { + const params = await props.params; + let users: ListUsersResponse["users"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/users`, + await authCookieHeader() + ); + users = res.data.data.users; + } catch (e) { + console.error("Error fetching users", e); + } + + const userRows: UserRow[] = users.map((user) => { + return { + id: user.id, + email: user.email, + }; + }); + + return ( + <> +
+

+ Manage Users +

+

+ Manage existing your users or invite new ones to your + organization. +

+
+ + + + ); +}