diff --git a/.changeset/migrate-users-create-openapi.md b/.changeset/migrate-users-create-openapi.md new file mode 100644 index 0000000000000..c84da7789b93e --- /dev/null +++ b/.changeset/migrate-users-create-openapi.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Add OpenAPI support for the `users.create` API endpoint by migrating to chained route definition with AJV body and response validation. diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 50c65abcd8d12..ba64328df2483 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,8 +1,7 @@ import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services'; -import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; +import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, IUserSettings, UserStatus } from '@rocket.chat/core-typings'; import { Users, Subscriptions, Sessions } from '@rocket.chat/models'; import { - isUserCreateParamsPOST, isUserSetActiveStatusParamsPOST, isUserDeactivateIdleParamsPOST, isUsersInfoParamsGetProps, @@ -300,51 +299,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.create', - { authRequired: true, validateParams: isUserCreateParamsPOST }, - { - async post() { - // New change made by pull request #5152 - if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { - this.bodyParams.joinDefaultChannels = true; - } - - if (this.bodyParams.name && !validateNameChars(this.bodyParams.name)) { - return API.v1.failure('Name contains invalid characters'); - } - - if (this.bodyParams.customFields) { - validateCustomFields(this.bodyParams.customFields); - } - - if (this.bodyParams.freeSwitchExtension && !(await canEditExtension(this.bodyParams.freeSwitchExtension))) { - return API.v1.failure('Setting user voice call extension is not allowed', 'error-action-not-allowed'); - } - - const newUserId = await saveUser(this.userId, this.bodyParams); - const userId = typeof newUserId !== 'string' ? this.userId : newUserId; - - if (this.bodyParams.customFields) { - await saveCustomFieldsWithoutValidation(userId, this.bodyParams.customFields); - } - - if (typeof this.bodyParams.active !== 'undefined') { - await executeSetUserActiveStatus(this.userId, userId, this.bodyParams.active); - } - - const { fields } = await this.parseJsonQuery(); - - const user = await Users.findOneById(userId, { projection: fields }); - if (!user) { - return API.v1.failure('User not found'); - } - - return API.v1.success({ user }); - }, - }, -); - API.v1.addRoute( 'users.delete', { authRequired: true, permissionsRequired: ['delete-user'] }, @@ -753,6 +707,55 @@ API.v1.addRoute( }, ); +type UserCreateParamsPOST = { + email: string; + name: string; + password: string; + username: string; + active?: boolean; + bio?: string; + nickname?: string; + statusText?: string; + roles?: string[]; + joinDefaultChannels?: boolean; + requirePasswordChange?: boolean; + setRandomPassword?: boolean; + sendWelcomeEmail?: boolean; + verified?: boolean; + customFields?: Record; + settings?: IUserSettings; + freeSwitchExtension?: string; + /* @deprecated */ + fields: string; +}; + +const userCreateParamsPostSchema = { + type: 'object', + properties: { + email: { type: 'string' }, + name: { type: 'string' }, + password: { type: 'string' }, + username: { type: 'string' }, + active: { type: 'boolean', nullable: true }, + bio: { type: 'string', nullable: true }, + nickname: { type: 'string', nullable: true }, + statusText: { type: 'string', nullable: true }, + roles: { type: 'array', items: { type: 'string' } }, + joinDefaultChannels: { type: 'boolean', nullable: true }, + requirePasswordChange: { type: 'boolean', nullable: true }, + setRandomPassword: { type: 'boolean', nullable: true }, + sendWelcomeEmail: { type: 'boolean', nullable: true }, + verified: { type: 'boolean', nullable: true }, + customFields: { type: 'object' }, + fields: { type: 'string', nullable: true }, + freeSwitchExtension: { type: 'string', nullable: true }, + }, + additionalProperties: false, + required: ['email', 'name', 'password', 'username'], +}; + +const isUserCreateParamsPOST = ajv.compile(userCreateParamsPostSchema); + const usersEndpoints = API.v1 .post( 'users.createToken', @@ -878,6 +881,68 @@ const usersEndpoints = API.v1 return API.v1.success({ suggestions }); }, + ) + .post( + 'users.create', + { + authRequired: true, + body: isUserCreateParamsPOST, + response: { + 200: ajv.compile<{ user: IUser }>({ + type: 'object', + properties: { + user: { $ref: '#/components/schemas/IUser' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + // New change made by pull request #5152 + if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { + this.bodyParams.joinDefaultChannels = true; + } + + if (this.bodyParams.name && !validateNameChars(this.bodyParams.name)) { + return API.v1.failure('Name contains invalid characters'); + } + + if (this.bodyParams.customFields) { + validateCustomFields(this.bodyParams.customFields); + } + + if (this.bodyParams.freeSwitchExtension && !(await canEditExtension(this.bodyParams.freeSwitchExtension))) { + return API.v1.failure('Setting user voice call extension is not allowed', 'error-action-not-allowed'); + } + + const newUserId = await saveUser(this.userId, this.bodyParams); + const userId = typeof newUserId !== 'string' ? this.userId : newUserId; + + if (this.bodyParams.customFields) { + await saveCustomFieldsWithoutValidation(userId, this.bodyParams.customFields); + } + + if (typeof this.bodyParams.active !== 'undefined') { + await executeSetUserActiveStatus(this.userId, userId, this.bodyParams.active); + } + + const { fields } = await this.parseJsonQuery(); + + const user = await Users.findOneById(userId, { projection: fields }); + if (!user) { + return API.v1.failure('User not found'); + } + + if ((user as unknown as Record).inactiveReason === null) { + delete (user as unknown as Record).inactiveReason; + } + + return API.v1.success({ user }); + }, ); API.v1.addRoute( diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index 1bf10554609a5..0227680127552 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -18,7 +18,7 @@ import { } from '@rocket.chat/fuselage'; import type { SelectOption } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import type { UserCreateParamsPOST } from '@rocket.chat/rest-typings'; +import type { OperationParams } from '@rocket.chat/rest-typings'; import { validateEmail } from '@rocket.chat/tools'; import { CustomFieldsForm, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client'; import { @@ -55,7 +55,7 @@ type AdminUserFormProps = { }; export type UserFormProps = Omit< - UserCreateParamsPOST & { avatar: AvatarObject; passwordConfirmation: string; freeSwitchExtension?: string }, + OperationParams<'POST', '/v1/users.create'> & { avatar: AvatarObject; passwordConfirmation: string; freeSwitchExtension?: string }, 'fields' >; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 565620d31ba5e..e5449b209efa0 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -3,7 +3,6 @@ import type { IExportOperation, ISubscription, ITeam, IUser, IPersonalAccessToke import { ajv } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; import type { PaginatedResult } from '../helpers/PaginatedResult'; -import type { UserCreateParamsPOST } from './users/UserCreateParamsPOST'; import type { UserDeactivateIdleParamsPOST } from './users/UserDeactivateIdleParamsPOST'; import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; @@ -271,12 +270,6 @@ export type UsersEndpoints = { }; }; - '/v1/users.create': { - POST: (params: UserCreateParamsPOST) => { - user: IUser; - }; - }; - '/v1/users.update': { POST: (params: UsersUpdateParamsPOST) => { user: IUser; @@ -370,7 +363,6 @@ export type UsersEndpoints = { }; }; -export * from './users/UserCreateParamsPOST'; export * from './users/UserSetActiveStatusParamsPOST'; export * from './users/UserDeactivateIdleParamsPOST'; export * from './users/UsersInfoParamsGet'; diff --git a/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts b/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts deleted file mode 100644 index c7be780e36438..0000000000000 --- a/packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { IUserSettings } from '@rocket.chat/core-typings'; - -import { ajv } from '../Ajv'; - -export type UserCreateParamsPOST = { - email: string; - name: string; - password: string; - username: string; - active?: boolean; - bio?: string; - nickname?: string; - statusText?: string; - roles?: string[]; - joinDefaultChannels?: boolean; - requirePasswordChange?: boolean; - setRandomPassword?: boolean; - sendWelcomeEmail?: boolean; - verified?: boolean; - customFields?: Record; - settings?: IUserSettings; - freeSwitchExtension?: string; - /* @deprecated */ - fields: string; -}; - -const userCreateParamsPostSchema = { - type: 'object', - properties: { - email: { type: 'string' }, - name: { type: 'string' }, - password: { type: 'string' }, - username: { type: 'string' }, - active: { type: 'boolean', nullable: true }, - bio: { type: 'string', nullable: true }, - nickname: { type: 'string', nullable: true }, - statusText: { type: 'string', nullable: true }, - roles: { type: 'array', items: { type: 'string' } }, - joinDefaultChannels: { type: 'boolean', nullable: true }, - requirePasswordChange: { type: 'boolean', nullable: true }, - setRandomPassword: { type: 'boolean', nullable: true }, - sendWelcomeEmail: { type: 'boolean', nullable: true }, - verified: { type: 'boolean', nullable: true }, - customFields: { type: 'object' }, - fields: { type: 'string', nullable: true }, - freeSwitchExtension: { type: 'string', nullable: true }, - }, - additionalProperties: false, - required: ['email', 'name', 'password', 'username'], -}; - -export const isUserCreateParamsPOST = ajv.compile(userCreateParamsPostSchema);