support server side table for admin users table

This commit is contained in:
miloschwartz
2026-04-20 22:05:29 -07:00
parent 6f06f98cc1
commit 85f7c1e87b
5 changed files with 464 additions and 461 deletions

View File

@@ -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<string>()
.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<string>()
.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<Awaited<ReturnType<typeof queryUsers>>>;
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 nonserver-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<number>`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<AdminListUsersResponse>(res, {
data: {
users: allUsers,
users: rows,
pagination: {
total: count,
limit,
offset
total,
page,
pageSize
}
},
success: true,