diff --git a/.changeset/migrate-users-register-openapi.md b/.changeset/migrate-users-register-openapi.md new file mode 100644 index 0000000000000..9a1f87217ed0a --- /dev/null +++ b/.changeset/migrate-users-register-openapi.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +"@rocket.chat/web-ui-registration": minor +--- + +Add OpenAPI support for the `users.register` 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..7730d5c8685ef 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -8,7 +8,6 @@ import { isUsersInfoParamsGetProps, isUsersListStatusProps, isUsersSendWelcomeEmailProps, - isUserRegisterParamsPOST, isUserLogoutParamsPOST, isUsersListTeamsProps, isUsersAutocompleteProps, @@ -660,73 +659,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.register', - { - authRequired: false, - rateLimiterOptions: { - numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1, - intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000, - }, - validateParams: isUserRegisterParamsPOST, - }, - { - async post() { - const { secret: secretURL, ...params } = this.bodyParams; - - if (this.userId) { - return API.v1.failure('Logged in users can not register again.'); - } - - if (params.name && !validateNameChars(params.name)) { - return API.v1.failure('Name contains invalid characters'); - } - - if (!validateUsername(this.bodyParams.username)) { - return API.v1.failure(`The username provided is not valid`); - } - - if (!(await checkUsernameAvailability(this.bodyParams.username))) { - return API.v1.failure('Username is already in use'); - } - if (!(await checkEmailAvailability(this.bodyParams.email))) { - return API.v1.failure('Email already exists'); - } - if (this.bodyParams.customFields) { - try { - await validateCustomFields(this.bodyParams.customFields); - } catch (e) { - return API.v1.failure(e); - } - } - - // Register the user - const userId = await registerUser({ - ...params, - ...(secretURL && { secretURL }), - }); - - if (typeof userId !== 'string') { - return API.v1.failure('Error creating user'); - } - - // Now set their username - const { fields } = await this.parseJsonQuery(); - await setUsernameWithValidation(userId, this.bodyParams.username); - - const user = await Users.findOneById(userId, { projection: fields }); - if (!user) { - return API.v1.failure('User not found'); - } - - if (this.bodyParams.customFields) { - await saveCustomFields(userId, this.bodyParams.customFields); - } - - return API.v1.success({ user }); - }, - }, -); API.v1.addRoute( 'users.resetAvatar', @@ -753,6 +685,52 @@ API.v1.addRoute( }, ); +type UserRegisterParamsPOST = { + username: string; + name?: string; + email: string; + pass: string; + secret?: string; + reason?: string; + customFields?: object; +}; + +const UserRegisterParamsPostSchema = { + type: 'object', + properties: { + username: { + type: 'string', + }, + + name: { + type: 'string', + nullable: true, + }, + email: { + type: 'string', + }, + pass: { + type: 'string', + }, + secret: { + type: 'string', + nullable: true, + }, + reason: { + type: 'string', + nullable: true, + }, + customFields: { + type: 'object', + nullable: true, + }, + }, + required: ['username', 'email', 'pass'], + additionalProperties: false, +}; + +const isUserRegisterParamsPOST = ajv.compile(UserRegisterParamsPostSchema); + const usersEndpoints = API.v1 .post( 'users.createToken', @@ -878,6 +856,87 @@ const usersEndpoints = API.v1 return API.v1.success({ suggestions }); }, + ) + .post( + 'users.register', + { + authRequired: false, + rateLimiterOptions: { + numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1, + intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000, + }, + body: isUserRegisterParamsPOST, + 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, + }, + }, + async function action() { + const { secret: secretURL, ...params } = this.bodyParams; + + if (this.userId) { + return API.v1.failure('Logged in users can not register again.'); + } + + if (params.name && !validateNameChars(params.name)) { + return API.v1.failure('Name contains invalid characters'); + } + + if (!validateUsername(this.bodyParams.username)) { + return API.v1.failure(`The username provided is not valid`); + } + + if (!(await checkUsernameAvailability(this.bodyParams.username))) { + return API.v1.failure('Username is already in use'); + } + if (!(await checkEmailAvailability(this.bodyParams.email))) { + return API.v1.failure('Email already exists'); + } + if (this.bodyParams.customFields) { + try { + await validateCustomFields(this.bodyParams.customFields); + } catch (e) { + return API.v1.failure(e instanceof Error ? e.message : String(e)); + } + } + + // Register the user + const userId = await registerUser({ + ...params, + ...(secretURL && { secretURL }), + }); + + if (typeof userId !== 'string') { + return API.v1.failure('Error creating user'); + } + + // Now set their username + const { fields } = await this.parseJsonQuery(); + await setUsernameWithValidation(userId, this.bodyParams.username); + + 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; + } + + if (this.bodyParams.customFields) { + await saveCustomFields(userId, this.bodyParams.customFields); + } + + return API.v1.success({ user }); + }, ); API.v1.addRoute( diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 565620d31ba5e..10eb4b7c19b05 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -6,7 +6,6 @@ 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'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; @@ -333,12 +332,6 @@ export type UsersEndpoints = { }; }; - '/v1/users.register': { - POST: (params: UserRegisterParamsPOST) => { - user: Partial; - }; - }; - '/v1/users.logout': { POST: (params: UserLogoutParamsPOST) => { message: string; @@ -376,7 +369,6 @@ export * from './users/UserDeactivateIdleParamsPOST'; export * from './users/UsersInfoParamsGet'; export * from './users/UsersListStatusParamsGET'; export * from './users/UsersSendWelcomeEmailParamsPOST'; -export * from './users/UserRegisterParamsPOST'; export * from './users/UserLogoutParamsPOST'; export * from './users/UsersListTeamsParamsGET'; export * from './users/UsersAutocompleteParamsGET'; diff --git a/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts b/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts deleted file mode 100644 index 1d3734126e450..0000000000000 --- a/packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ajv } from '../Ajv'; - -export type UserRegisterParamsPOST = { - username: string; - name?: string; - email: string; - pass: string; - secret?: string; - reason?: string; - customFields?: object; -}; - -const UserRegisterParamsPostSchema = { - type: 'object', - properties: { - username: { - type: 'string', - }, - - name: { - type: 'string', - nullable: true, - }, - email: { - type: 'string', - }, - pass: { - type: 'string', - }, - secret: { - type: 'string', - nullable: true, - }, - reason: { - type: 'string', - nullable: true, - }, - customFields: { - type: 'object', - nullable: true, - }, - }, - required: ['username', 'email', 'pass'], - additionalProperties: false, -}; - -export const isUserRegisterParamsPOST = ajv.compile(UserRegisterParamsPostSchema); diff --git a/packages/web-ui-registration/src/users-register.d.ts b/packages/web-ui-registration/src/users-register.d.ts new file mode 100644 index 0000000000000..f64b7bf729b56 --- /dev/null +++ b/packages/web-ui-registration/src/users-register.d.ts @@ -0,0 +1,19 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +declare module '@rocket.chat/rest-typings' { + interface Endpoints { + '/v1/users.register': { + POST: (params: { + username: string; + name?: string; + email: string; + pass: string; + secret?: string; + reason?: string; + customFields?: object; + }) => { + user: IUser; + }; + }; + } +}