diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 40ca7ef2f..86fc9b770 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -5,33 +5,67 @@ import { idp, roles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, sql } from "drizzle-orm"; +import { and, asc, desc, like, or, sql, type SQL } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { eq } from "drizzle-orm"; +import type { PaginatedResponse } from "@server/types/Pagination"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() }); const listUsersSchema = z.strictObject({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["username"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["username"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }) }); -async function queryUsers(orgId: string, limit: number, offset: number) { - return await db +function queryUsersBase() { + return db .select({ id: users.userId, email: users.email, @@ -54,16 +88,12 @@ async function queryUsers(orgId: string, limit: number, offset: number) { .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) - .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) - .where(eq(userOrgs.orgId, orgId)) - .limit(limit) - .offset(offset); + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)); } -export type ListUsersResponse = { - users: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; -}; +export type ListUsersResponse = PaginatedResponse<{ + users: NonNullable>>; +}>; registry.registerPath({ method: "get", @@ -92,7 +122,7 @@ export async function listUsers( ) ); } - const { limit, offset } = parsedQuery.data; + const { page, pageSize, sort_by, order, query } = parsedQuery.data; const parsedParams = listUsersParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -106,24 +136,57 @@ export async function listUsers( const { orgId } = parsedParams.data; - const usersWithRoles = await queryUsers( - orgId.toString(), - limit, - offset + const conditions = [and(eq(userOrgs.orgId, orgId))]; + + if (query) { + conditions.push( + or( + like( + sql`LOWER(${users.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.username})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.email})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } + + const countQuery = db.$count( + queryUsersBase() + .where(and(...conditions)) + .as("filtered_users") ); - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(userOrgs) - .where(eq(userOrgs.orgId, orgId)); + const userListQuery = queryUsersBase() + .where(and(...conditions)) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(users[sort_by]) + : desc(users[sort_by]) + : asc(users.name) + ); + + const [count, usersWithRoles] = await Promise.all([ + countQuery, + userListQuery + ]); return response(res, { data: { users: usersWithRoles, pagination: { total: count, - limit, - offset + page, + pageSize } }, success: true, diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index c10363734..5297e747e 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -3,40 +3,46 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { ListUsersResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; -import UsersTable, { UserRow } from "../../../../../components/UsersTable"; +import UsersTable, { UserRow } from "@app/components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; -import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; type UsersPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function UsersPage(props: UsersPageProps) { const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); - const getUser = cache(verifySession); - const user = await getUser(); + const user = await verifySession(); const t = await getTranslations(); let users: ListUsersResponse["users"] = []; + let pagination: ListUsersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; let hasInvitations = false; const res = await internal .get< AxiosResponse - >(`/org/${params.orgId}/users`, await authCookieHeader()) + >(`/org/${params.orgId}/users?${searchParams.toString()}`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { users = res.data.data.users; + pagination = res.data.data.pagination; } const invitationsRes = await internal diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 9b1dfee68..c6a02ab69 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, type PaginationState } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, @@ -9,14 +9,22 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ArrowUpDown, + ChevronsUpDownIcon, + Crown, + MoreHorizontal +} from "lucide-react"; import { UsersDataTable } from "@app/components/UsersDataTable"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useTransition } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; @@ -24,6 +32,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "./IdpTypeBadge"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import type { filter } from "d3"; +import { useDebouncedCallback } from "use-debounce"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; export type UserRow = { id: string; @@ -42,39 +55,44 @@ export type UserRow = { type UsersTableProps = { users: UserRow[]; + pagination: PaginationState; + rowCount: number; }; -export default function UsersTable({ users: u }: UsersTableProps) { +export default function UsersTable({ + users, + pagination, + rowCount +}: UsersTableProps) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [users, setUsers] = useState(u); const router = useRouter(); const api = createApiClient(useEnvContext()); const { user, updateUser } = useUserContext(); const { org } = useOrgContext(); const t = useTranslations(); - const [isRefreshing, setIsRefreshing] = useState(false); - - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setUsers(u); - }, [u]); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + const [isRefreshing, startTransition] = useTransition(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } + startTransition(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); }; const columns: ExtendedColumnDef[] = [ @@ -83,15 +101,21 @@ export default function UsersTable({ users: u }: UsersTableProps) { enableHiding: false, friendlyName: t("username"), header: ({ column }) => { + const nameOrder = getSortDirection("username", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -100,17 +124,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { accessorKey: "idpName", friendlyName: t("identityProvider"), header: ({ column }) => { - return ( - - ); + return {t("identityProvider")}; }, cell: ({ row }) => { const userRow = row.original; @@ -127,17 +141,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { accessorKey: "role", friendlyName: t("role"), header: ({ column }) => { - return ( - - ); + return {t("role")}; }, cell: ({ row }) => { const userRow = row.original; @@ -184,9 +188,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { isDisabled && e.preventDefault() } > - + {t("accessUsersManage")} @@ -218,10 +220,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { - @@ -256,15 +255,36 @@ export default function UsersTable({ users: u }: UsersTableProps) { email: selectedUser.email || "" }) }); - - setUsers((prev) => - prev.filter((u) => u.id !== selectedUser?.id) - ); } } + router.refresh(); setIsDeleteModalOpen(false); } + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + return ( <> } buttonText={t("userRemoveOrgConfirm")} - onConfirm={removeUser} + onConfirm={async () => startTransition(removeUser)} string={ selectedUser ? getUserDisplayName({ @@ -293,12 +313,22 @@ export default function UsersTable({ users: u }: UsersTableProps) { title={t("userRemoveOrg")} /> - { - router.push( - `/${org?.org.orgId}/settings/access/users/create` + pagination={pagination} + rowCount={rowCount} + isNavigatingToAddPage={isNavigatingToAddPage} + searchQuery={searchParams.get("query")?.toString()} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + rows={users} + searchPlaceholder={t("accessUsersSearch")} + tableId="users-table" + onAdd={() => { + startNavigation(() => + router.push( + `/${org?.org.orgId}/settings/access/users/create` + ) ); }} onRefresh={refreshData}