import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas'; import { passwordRegEx, usernameRegEx } from '@logto/shared'; import { has } from '@silverhand/essentials'; import pick from 'lodash.pick'; import { InvalidInputError } from 'slonik'; import { literal, object, string } from 'zod'; import RequestError from '@/errors/RequestError'; import { encryptUserPassword, generateUserId } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import koaPagination from '@/middleware/koa-pagination'; import { findRolesByRoleNames } from '@/queries/roles'; import { deleteUserById, deleteUserIdentity, findUsers, countUsers, findUserById, hasUser, insertUser, updateUserById, } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { AuthedRouter } from './types'; export default function adminUserRoutes(router: T) { router.get( '/users', koaPagination(), koaGuard({ query: object({ search: string().optional(), hideAdminUser: literal('true').optional() }), }), async (ctx, next) => { const { limit, offset } = ctx.pagination; const { query: { search, hideAdminUser: _hideAdminUser }, } = ctx.guard; const hideAdminUser = _hideAdminUser === 'true'; const [{ count }, users] = await Promise.all([ countUsers(search, hideAdminUser), findUsers(limit, offset, search, hideAdminUser), ]); ctx.pagination.totalCount = count; ctx.body = users.map((user) => pick(user, ...userInfoSelectFields)); return next(); } ); router.get( '/users/:userId', // TODO: No need to guard koaGuard({ params: object({ userId: string() }), }), async (ctx, next) => { const { params: { userId }, } = ctx.guard; const user = await findUserById(userId); ctx.body = pick(user, ...userInfoSelectFields); return next(); } ); router.post( '/users', koaGuard({ body: object({ username: string().regex(usernameRegEx), password: string().regex(passwordRegEx), name: string(), }), }), async (ctx, next) => { const { username, password, name } = ctx.guard.body; assertThat( !(await hasUser(username)), new RequestError({ code: 'user.username_exists_register', status: 422, }) ); const id = await generateUserId(); const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); const user = await insertUser({ id, username, passwordEncrypted, passwordEncryptionMethod, name, }); ctx.body = pick(user, ...userInfoSelectFields); return next(); } ); router.patch( '/users/:userId', koaGuard({ params: object({ userId: string() }), body: object({ name: string().nullable().optional(), avatar: string().url().or(literal('')).nullable().optional(), customData: arbitraryObjectGuard.optional(), roleNames: string().array().optional(), }), }), async (ctx, next) => { const { params: { userId }, body, } = ctx.guard; await findUserById(userId); // Temp solution to validate the existence of input roleNames if (body.roleNames?.length) { const { roleNames } = body; const roles = await findRolesByRoleNames(roleNames); if (roles.length !== roleNames.length) { const resourcesNotFound = roleNames.filter( (roleName) => !roles.some(({ name }) => roleName === name) ); // TODO: Should be cached by the error handler and return request error throw new InvalidInputError(`role names (${resourcesNotFound.join(',')}) are not valid`); } } const user = await updateUserById( userId, { ...body, }, 'replace' ); ctx.body = pick(user, ...userInfoSelectFields); return next(); } ); router.patch( '/users/:userId/password', koaGuard({ params: object({ userId: string() }), body: object({ password: string().regex(passwordRegEx) }), }), async (ctx, next) => { const { params: { userId }, body: { password }, } = ctx.guard; await findUserById(userId); const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); const user = await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod, }); ctx.body = pick(user, ...userInfoSelectFields); return next(); } ); router.delete( '/users/:userId', koaGuard({ params: object({ userId: string() }), }), async (ctx, next) => { const { params: { userId }, } = ctx.guard; await findUserById(userId); await deleteUserById(userId); ctx.status = 204; return next(); } ); router.delete( '/users/:userId/identities/:target', koaGuard({ params: object({ userId: string(), target: string() }) }), async (ctx, next) => { const { params: { userId, target }, } = ctx.guard; const { identities } = await findUserById(userId); if (!has(identities, target)) { throw new RequestError({ code: 'user.identity_not_exists', status: 404 }); } const updatedUser = await deleteUserIdentity(userId, target); ctx.body = pick(updatedUser, ...userInfoSelectFields); return next(); } ); }