diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 6c39fe98..7272d740 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -689,6 +689,13 @@ authenticated.get( user.getOrgUser ); +authenticated.get( + "/org/:orgId/user-by-username", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrgUser), + user.getOrgUserByUsername +); + authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index f22a29d3..c0a990ee 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; -async function queryUser(orgId: string, userId: string) { +export async function queryUser(orgId: string, userId: string) { const [user] = await db .select({ orgId: userOrgs.orgId, diff --git a/server/routers/user/getOrgUserByUsername.ts b/server/routers/user/getOrgUserByUsername.ts new file mode 100644 index 00000000..b047fdc0 --- /dev/null +++ b/server/routers/user/getOrgUserByUsername.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userOrgs, users } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { queryUser, type GetOrgUserResponse } from "./getOrgUser"; + +const getOrgUserByUsernameParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const getOrgUserByUsernameQuerySchema = z.strictObject({ + username: z.string().min(1, "username is required"), + idpId: z + .string() + .optional() + .transform((v) => + v === undefined || v === "" ? undefined : parseInt(v, 10) + ) + .refine( + (v) => + v === undefined || (Number.isInteger(v) && (v as number) > 0), + { message: "idpId must be a positive integer" } + ) +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user-by-username", + description: + "Get a user in an organization by username. When idpId is not passed, only internal users are searched (username is globally unique for them). For external (OIDC) users, pass idpId to search by username within that identity provider.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: getOrgUserByUsernameParamsSchema, + query: getOrgUserByUsernameQuerySchema + }, + responses: {} +}); + +export async function getOrgUserByUsername( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getOrgUserByUsernameParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = getOrgUserByUsernameQuerySchema.safeParse( + req.query + ); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { username, idpId } = parsedQuery.data; + + const conditions = [ + eq(userOrgs.orgId, orgId), + eq(users.username, username) + ]; + if (idpId !== undefined) { + conditions.push(eq(users.idpId, idpId)); + } else { + conditions.push(eq(users.type, "internal")); + } + + const candidates = await db + .select({ userId: users.userId }) + .from(userOrgs) + .innerJoin(users, eq(userOrgs.userId, users.userId)) + .where(and(...conditions)); + + if (candidates.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with username '${username}' not found in organization` + ) + ); + } + + if (candidates.length > 1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Multiple users with this username (external users from different identity providers). Specify idpId (identity provider ID) to disambiguate. When not specified, this searches for internal users only." + ) + ); + } + + const user = await queryUser(orgId, candidates[0].userId); + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with username '${username}' not found in organization` + ) + ); + } + + return response(res, { + data: user, + success: true, + error: false, + message: "User retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 35c5c4a7..b6fb05d9 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -5,6 +5,7 @@ export * from "./addUserRole"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; +export * from "./getOrgUserByUsername"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./adminGetUser";