diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index 3a965259c..3d7bac4b3 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -1,31 +1,98 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, idp, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq } from "drizzle-orm"; +import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; import logger from "@server/logger"; -import { idp, users } from "@server/db"; import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { UserType } from "@server/types/UserTypes"; const listUsersSchema = z.strictObject({ - limit: z - .string() + pageSize: z.coerce + .number() + .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() + .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", "email", "name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["username", "email", "name"], + 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" + }), + idp_id: z + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (val === "internal") { + return "internal"; + } + if (typeof val === "string" && /^\d+$/.test(val)) { + return parseInt(val, 10); + } + return undefined; + }, + z + .union([z.literal("internal"), z.number().int().positive()]) + .optional() + ) + .openapi({ + description: + 'Filter by identity provider id, or "internal" for internal users' + }), + two_factor: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: + "Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested" + }) }); -async function queryUsers(limit: number, offset: number) { - return await db +function queryUsersBase() { + return db .select({ id: users.userId, email: users.email, @@ -40,17 +107,39 @@ async function queryUsers(limit: number, offset: number) { twoFactorSetupRequested: users.twoFactorSetupRequested }) .from(users) - .leftJoin(idp, eq(users.idpId, idp.idpId)) - .where(eq(users.serverAdmin, false)) - .limit(limit) - .offset(offset); + .leftJoin(idp, eq(users.idpId, idp.idpId)); } -export type AdminListUsersResponse = { - users: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; +/** Row shape returned by `queryUsersBase()` (matches selected columns + join). */ +export type AdminListUserRow = { + id: string; + email: string | null; + username: string; + name: string | null; + dateCreated: string; + serverAdmin: boolean; + type: string; + idpName: string | null; + idpId: number | null; + twoFactorEnabled: boolean; + twoFactorSetupRequested: boolean | null; }; +export type AdminListUsersResponse = PaginatedResponse<{ + users: AdminListUserRow[]; +}>; + +registry.registerPath({ + method: "get", + path: "/users", + description: "List non–server-admin users (server admin).", + tags: [OpenAPITags.User], + request: { + query: listUsersSchema + }, + responses: {} +}); + export async function adminListUsers( req: Request, res: Response, @@ -66,21 +155,96 @@ export async function adminListUsers( ) ); } - const { limit, offset } = parsedQuery.data; + const { + page, + pageSize, + query, + sort_by, + order, + idp_id, + two_factor: twoFactorFilter + } = parsedQuery.data; - const allUsers = await queryUsers(limit, offset); + if (typeof idp_id === "number") { + const idpOk = await db + .select({ one: sql`1` }) + .from(idp) + .where(eq(idp.idpId, idp_id)) + .limit(1); + if (idpOk.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "idp_id does not exist" + ) + ); + } + } - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(users); + const conditions = [eq(users.serverAdmin, false)]; + + if (query) { + const q = "%" + query.toLowerCase() + "%"; + conditions.push( + or( + like(sql`LOWER(${users.username})`, q), + like(sql`LOWER(${users.email})`, q), + like(sql`LOWER(${users.name})`, q) + )! + ); + } + + if (idp_id === "internal") { + conditions.push(eq(users.type, UserType.Internal)); + } else if (typeof idp_id === "number") { + conditions.push(eq(users.idpId, idp_id)); + } + + if (typeof twoFactorFilter === "boolean") { + if (twoFactorFilter) { + conditions.push( + or( + eq(users.twoFactorEnabled, true), + eq(users.twoFactorSetupRequested, true) + )! + ); + } else { + conditions.push( + and( + eq(users.twoFactorEnabled, false), + eq(users.twoFactorSetupRequested, false) + )! + ); + } + } + + const whereClause = and(...conditions); + + const countQuery = db.$count( + queryUsersBase().where(whereClause).as("filtered_admin_users") + ); + + const userListQuery = queryUsersBase() + .where(whereClause) + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(users[sort_by]) + : desc(users[sort_by]) + : asc(users.username) + ); + + const [total, rows] = await Promise.all([countQuery, userListQuery]); return response(res, { data: { - users: allUsers, + users: rows, pagination: { - total: count, - limit, - offset + total, + page, + pageSize } }, success: true, diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx deleted file mode 100644 index 1c7d1b7fd..000000000 --- a/src/app/admin/users/AdminUsersTable.tsx +++ /dev/null @@ -1,264 +0,0 @@ -"use client"; - -import { UsersDataTable } from "@app/components/AdminUsersDataTable"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } 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"; -import { useTranslations } from "next-intl"; -import { - DropdownMenu, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenuTrigger -} from "@app/components/ui/dropdown-menu"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; - -export type GlobalUserRow = { - id: string; - name: string | null; - username: string; - email: string | null; - type: string; - idpId: number | null; - idpName: string; - dateCreated: string; - twoFactorEnabled: boolean | null; - twoFactorSetupRequested: boolean | null; -}; - -type Props = { - users: GlobalUserRow[]; -}; - -export default function UsersTable({ users }: Props) { - const router = useRouter(); - const t = useTranslations(); - - 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(t("userErrorDelete"), e); - toast({ - variant: "destructive", - title: t("userErrorDelete"), - description: formatAxiosError(e, t("userErrorDelete")) - }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== id); - - setRows(newRows); - }); - }; - - const columns: ExtendedColumnDef[] = [ - { - accessorKey: "id", - friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "username", - friendlyName: t("username"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "email", - friendlyName: t("email"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "name", - friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "idpName", - friendlyName: t("identityProvider"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "twoFactorEnabled", - friendlyName: t("twoFactor"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const userRow = row.original; - - return ( -
- - {userRow.twoFactorEnabled || - userRow.twoFactorSetupRequested ? ( - - {t("enabled")} - - ) : ( - {t("disabled")} - )} - -
- ); - } - }, - { - id: "actions", - header: () => {t("actions")}, - cell: ({ row }) => { - const r = row.original; - return ( - <> -
- - - - - - - { - setSelected(r); - setIsDeleteModalOpen(true); - }} - > - {t("delete")} - - - -
- - ); - } - } - ]; - - return ( - <> - {selected && ( - { - setIsDeleteModalOpen(val); - setSelected(null); - }} - dialog={ -
-

{t("userQuestionRemove")}

- -

{t("userMessageRemove")}

-
- } - buttonText={t("userDeleteConfirm")} - onConfirm={async () => deleteUser(selected!.id)} - string={ - selected.email || selected.name || selected.username - } - title={t("userDeleteServer")} - /> - )} - - - - ); -} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 2a000b34b..0cfaaf3b0 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,33 +1,70 @@ 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 type { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; +import type { ListIdpsResponse } from "@server/routers/idp/listIdps"; import UsersTable, { GlobalUserRow } from "@app/components/AdminUsersTable"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; -type PageProps = { - params: Promise<{ orgId: string }>; +/** API JSON body shape for `response()` handlers (see `server/lib/response.ts`). */ +type ApiPayload = { + data: T; + success: boolean; + error: boolean; + message: string; + status: number; +}; + +type AdminUsersPageProps = { + searchParams: Promise>; }; export const dynamic = "force-dynamic"; -export default async function UsersPage(props: PageProps) { +export default async function UsersPage(props: AdminUsersPageProps) { + const searchParams = new URLSearchParams(await props.searchParams); + const cookieHeader = await authCookieHeader(); + let rows: AdminListUsersResponse["users"] = []; - try { - const res = await internal.get>( - `/users`, - await authCookieHeader() - ); - rows = res.data.data.users; - } catch (e) { - console.error(e); + let pagination: AdminListUsersResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + const [usersRes, idpsRes] = await Promise.all([ + internal + .get< + ApiPayload + >(`/users?${searchParams.toString()}`, cookieHeader) + .catch(() => {}), + internal + .get< + ApiPayload + >(`/idp?limit=500&offset=0`, cookieHeader) + .catch(() => {}) + ]); + + if (usersRes && usersRes.status === 200) { + const list = usersRes.data.data; + rows = list.users; + pagination = list.pagination; } const t = await getTranslations(); + const globalIdps = + idpsRes && idpsRes.status === 200 ? (idpsRes.data.data.idps ?? []) : []; + const idpFilterOptions = [ + { value: "internal", label: t("idpNameInternal") }, + ...globalIdps.map((i: ListIdpsResponse["idps"][number]) => ({ + value: String(i.idpId), + label: i.name + })) + ]; + const userRows: GlobalUserRow[] = rows.map((row) => { return { id: row.id, @@ -59,7 +96,15 @@ export default async function UsersPage(props: PageProps) { {t("userAbountDescription")} - + ); } diff --git a/src/components/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx deleted file mode 100644 index afa473e86..000000000 --- a/src/components/AdminUsersDataTable.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from "next-intl"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - onRefresh?: () => void; - isRefreshing?: boolean; -} - -export function UsersDataTable({ - columns, - data, - onRefresh, - isRefreshing -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index 09797a2e2..eabb6b468 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -1,19 +1,31 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { UsersDataTable } from "@app/components/AdminUsersDataTable"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState, useEffect } 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 { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { Button } from "@app/components/ui/button"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowRight, + ArrowUp10Icon, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; import { DropdownMenu, DropdownMenuItem, @@ -31,7 +43,6 @@ import { CredenzaClose } from "@app/components/Credenza"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { AxiosResponse } from "axios"; export type GlobalUserRow = { id: string; @@ -44,10 +55,16 @@ export type GlobalUserRow = { dateCreated: string; twoFactorEnabled: boolean | null; twoFactorSetupRequested: boolean | null; + serverAdmin?: boolean; }; +type FilterOption = { value: string; label: string }; + type Props = { users: GlobalUserRow[]; + pagination: PaginationState; + rowCount: number; + idpFilterOptions: FilterOption[]; }; type AdminGeneratePasswordResetCodeResponse = { @@ -56,74 +73,103 @@ type AdminGeneratePasswordResetCodeResponse = { url: string; }; -export default function UsersTable({ users }: Props) { +export default function UsersTable({ + users, + pagination, + rowCount, + idpFilterOptions +}: Props) { const router = useRouter(); const t = useTranslations(); + const api = createApiClient(useEnvContext()); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selected, setSelected] = useState(null); - const [rows, setRows] = useState(users); - - const api = createApiClient(useEnvContext()); - - const [isRefreshing, setIsRefreshing] = useState(false); const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] = useState(false); const [passwordResetCodeData, setPasswordResetCodeData] = useState(null); const [isGeneratingCode, setIsGeneratingCode] = useState(false); - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setRows(users); - }, [users]); + const [isRefreshing, startTransition] = useTransition(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams, + pathname + } = useNavigationContext(); + + const idpIdParamSchema = z + .union([z.literal("internal"), z.string().regex(/^\d+$/)]) + .optional() + .catch(undefined); + + const twoFactorFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } 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 deleteUser = (id: string) => { - api.delete(`/user/${id}`) - .catch((e) => { - console.error(t("userErrorDelete"), e); - toast({ - variant: "destructive", - title: t("userErrorDelete"), - description: formatAxiosError(e, t("userErrorDelete")) + startTransition(() => { + void api + .delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + setSelected(null); }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - - const newRows = rows.filter((row) => row.id !== id); - - setRows(newRows); - }); + }); }; const generatePasswordResetCode = async (userId: string) => { setIsGeneratingCode(true); try { - const res = await api.post< - AxiosResponse - >(`/user/${userId}/generate-password-reset-code`); + const res = await api.post( + `/user/${userId}/generate-password-reset-code` + ); - if (res.data?.data) { - setPasswordResetCodeData(res.data.data); + const envelope = res.data as { + data?: AdminGeneratePasswordResetCodeResponse; + }; + if (envelope?.data) { + setPasswordResetCodeData(envelope.data); setIsPasswordResetCodeDialogOpen(true); } } catch (e) { @@ -138,37 +184,55 @@ export default function UsersTable({ users }: Props) { } }; + 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); + const columns: ExtendedColumnDef[] = [ { accessorKey: "id", friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } + header: () => ID }, { accessorKey: "username", enableHiding: false, friendlyName: t("username"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("username", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -176,16 +240,22 @@ export default function UsersTable({ users }: Props) { { accessorKey: "email", friendlyName: t("email"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("email", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -193,16 +263,22 @@ export default function UsersTable({ users }: Props) { { accessorKey: "name", friendlyName: t("name"), - header: ({ column }) => { + header: () => { + const sortOrder = getSortDirection("name", searchParams); + const Icon = + sortOrder === "asc" + ? ArrowDown01Icon + : sortOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -210,39 +286,45 @@ export default function UsersTable({ users }: Props) { { accessorKey: "idpName", friendlyName: t("identityProvider"), - header: ({ column }) => { - return ( - - ); - } + header: () => ( + + handleFilterChange("idp_id", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("identityProvider")} + className="p-3" + /> + ) }, { accessorKey: "twoFactorEnabled", friendlyName: t("twoFactor"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("two_factor", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("twoFactor")} + className="p-3" + /> + ), cell: ({ row }) => { const userRow = row.original; - return (
@@ -277,8 +359,11 @@ export default function UsersTable({ users }: Props) { {r.type === "internal" && ( { - generatePasswordResetCode(r.id); + void generatePasswordResetCode( + r.id + ); }} > {t("generatePasswordResetCode")} @@ -350,11 +435,21 @@ export default function UsersTable({ users }: Props) { /> )} -