From 29d71a2f18203560da167bbb7e0e827f6ab28b0e Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 29 May 2026 00:25:04 -0300 Subject: [PATCH 1/5] chore: migrate batch4 client DDP callers + add 7 REST endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoints (with deprecation logs on their DDP counterparts): - POST /v1/users.verifyEmail (verifyEmail + afterVerifyEmail) - POST /v1/cloud.connectWorkspace (cloud:connectWorkspace) - POST /v1/permissions.addRole (authorization:addPermissionToRole) - POST /v1/permissions.removeRole (authorization:removeRoleFromPermission) - POST /v1/integrations.history.clear (clearIntegrationHistory) - POST /v1/integrations.outgoing.replay (replayOutgoingIntegration) - GET /v1/rooms.getByTypeAndName (getRoomByTypeAndName) Existing endpoint extensions: - POST /v1/users.setAvatar now accepts an optional 'service' multipart field, storing it as the user's avatarOrigin (replaces the setAvatarFromService DDP method). Server-side hook: - afterLogoutCleanUpCallback + Apps.IPostUserLoggedOut fire from a new Accounts.onLogout handler and from POST /v1/users.logout, so the client no longer needs to invoke logoutCleanUp after a DDP logout. Client callers swapped: - UserProvider drops sdk.call('logoutCleanUp') - useUpdateAvatar sends 'service' through /v1/users.setAvatar - useOpenRoom + EmbeddedPreload → /v1/rooms.getByTypeAndName - RegisterWorkspaceTokenModal → /v1/cloud.connectWorkspace - meteor/startup/accounts.ts collapses verify+after into single REST - PermissionsTable → /v1/permissions.{add,remove}Role - OutgoingWebhookHistoryPage + HistoryItem → integrations endpoints DDP methods stay registered with deprecation logs pointing at the new routes for external SDK/mobile clients until 9.0.0 removes them. Co-Authored-By: Claude Opus 4.7 --- .changeset/ddp-migrate-batch4-callers.md | 13 +++ .changeset/rest-batch4-logout-cleanup.md | 5 ++ .changeset/rest-cloud-connect-workspace.md | 6 ++ .../rest-integrations-history-clear-replay.md | 6 ++ .../rest-permissions-add-remove-role.md | 6 ++ .changeset/rest-rooms-get-by-type-and-name.md | 6 ++ .changeset/rest-users-setavatar-service.md | 5 ++ .changeset/rest-users-verify-email.md | 6 ++ apps/meteor/app/api/server/v1/cloud.ts | 21 +++++ apps/meteor/app/api/server/v1/integrations.ts | 50 +++++++++++ apps/meteor/app/api/server/v1/permissions.ts | 56 +++++++++++++ apps/meteor/app/api/server/v1/rooms.ts | 32 ++++++- apps/meteor/app/api/server/v1/users.ts | 44 +++++++++- .../server/functions/permissionRole.ts | 78 +++++++++++++++++ .../server/methods/addPermissionToRole.ts | 50 +---------- .../methods/removeRoleFromPermission.ts | 37 +------- apps/meteor/app/cloud/server/methods.ts | 1 + .../functions/clearIntegrationHistory.ts | 70 ++++++++++++++++ .../server/methods/clearIntegrationHistory.ts | 39 +-------- .../outgoing/replayOutgoingIntegration.ts | 45 +--------- apps/meteor/client/hooks/useUpdateAvatar.ts | 28 ++----- apps/meteor/client/meteor/startup/accounts.ts | 3 +- .../providers/UserProvider/UserProvider.tsx | 18 +--- .../outgoing/history/HistoryItem.tsx | 4 +- .../history/OutgoingWebhookHistoryPage.tsx | 6 +- .../PermissionsTable/PermissionsTable.tsx | 21 ++++- .../modals/RegisterWorkspaceTokenModal.tsx | 10 +-- .../client/views/room/hooks/useOpenRoom.ts | 6 +- .../views/root/MainLayout/EmbeddedPreload.tsx | 6 +- apps/meteor/server/hooks/index.ts | 1 + apps/meteor/server/hooks/userLogoutCleanUp.ts | 21 +++++ .../server/lib/users/runAfterVerifyEmail.ts | 31 +++++++ .../meteor/server/methods/afterVerifyEmail.ts | 29 ++----- apps/meteor/server/methods/logoutCleanUp.ts | 12 +-- apps/meteor/server/publications/room/index.ts | 84 ++++++++++--------- packages/rest-typings/src/v1/cloud.ts | 19 +++++ .../IntegrationsClearHistoryProps.ts | 16 ++++ .../integrations/IntegrationsReplayProps.ts | 18 ++++ .../rest-typings/src/v1/integrations/index.ts | 2 + .../src/v1/integrations/integrations.ts | 10 +++ packages/rest-typings/src/v1/rooms.ts | 20 +++++ packages/rest-typings/src/v1/users.ts | 4 + 42 files changed, 660 insertions(+), 285 deletions(-) create mode 100644 .changeset/ddp-migrate-batch4-callers.md create mode 100644 .changeset/rest-batch4-logout-cleanup.md create mode 100644 .changeset/rest-cloud-connect-workspace.md create mode 100644 .changeset/rest-integrations-history-clear-replay.md create mode 100644 .changeset/rest-permissions-add-remove-role.md create mode 100644 .changeset/rest-rooms-get-by-type-and-name.md create mode 100644 .changeset/rest-users-setavatar-service.md create mode 100644 .changeset/rest-users-verify-email.md create mode 100644 apps/meteor/app/authorization/server/functions/permissionRole.ts create mode 100644 apps/meteor/app/integrations/server/functions/clearIntegrationHistory.ts create mode 100644 apps/meteor/server/hooks/userLogoutCleanUp.ts create mode 100644 apps/meteor/server/lib/users/runAfterVerifyEmail.ts create mode 100644 packages/rest-typings/src/v1/integrations/IntegrationsClearHistoryProps.ts create mode 100644 packages/rest-typings/src/v1/integrations/IntegrationsReplayProps.ts diff --git a/.changeset/ddp-migrate-batch4-callers.md b/.changeset/ddp-migrate-batch4-callers.md new file mode 100644 index 0000000000000..9cb97b175bf96 --- /dev/null +++ b/.changeset/ddp-migrate-batch4-callers.md @@ -0,0 +1,13 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrate seven client DDP callers to their new REST equivalents (DDP methods stay registered for external SDK/mobile clients with deprecation logs until 9.0.0): + +- `logoutCleanUp` → side effects now run server-side via `Accounts.onLogout` + `POST /v1/users.logout`; client `sdk.call` dropped. +- `setAvatarFromService` → `POST /v1/users.setAvatar` (new `service` multipart field). +- `getRoomByTypeAndName` → `GET /v1/rooms.getByTypeAndName`. +- `cloud:connectWorkspace` → `POST /v1/cloud.connectWorkspace`. +- `verifyEmail` + `afterVerifyEmail` → single `POST /v1/users.verifyEmail` call. +- `authorization:addPermissionToRole` / `removeRoleFromPermission` → `POST /v1/permissions.addRole` / `POST /v1/permissions.removeRole`. +- `clearIntegrationHistory` / `replayOutgoingIntegration` → `POST /v1/integrations.history.clear` / `POST /v1/integrations.outgoing.replay`. diff --git a/.changeset/rest-batch4-logout-cleanup.md b/.changeset/rest-batch4-logout-cleanup.md new file mode 100644 index 0000000000000..f9d11607b7a0e --- /dev/null +++ b/.changeset/rest-batch4-logout-cleanup.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Moved the post-logout cleanup hook (`afterLogoutCleanUpCallback` + `Apps.IPostUserLoggedOut`) into a server-side `Accounts.onLogout` handler and into `POST /v1/users.logout`. Both DDP and REST logout paths now fire those callbacks server-side; the client no longer needs to invoke `logoutCleanUp` after detecting a logout, and the deprecated DDP method keeps its registration with a deprecation log pointing at `/v1/users.logout`. diff --git a/.changeset/rest-cloud-connect-workspace.md b/.changeset/rest-cloud-connect-workspace.md new file mode 100644 index 0000000000000..63f5ac3751f7a --- /dev/null +++ b/.changeset/rest-cloud-connect-workspace.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/cloud.connectWorkspace` (replaces the deprecated `cloud:connectWorkspace` DDP method). Body is `{ token }`; auth-gated with `manage-cloud` permission. The legacy DDP method remains registered with a deprecation log pointing at the new route. diff --git a/.changeset/rest-integrations-history-clear-replay.md b/.changeset/rest-integrations-history-clear-replay.md new file mode 100644 index 0000000000000..c1537cdc3d79e --- /dev/null +++ b/.changeset/rest-integrations-history-clear-replay.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/integrations.history.clear` and `POST /v1/integrations.outgoing.replay` (replace the deprecated `clearIntegrationHistory` and `replayOutgoingIntegration` DDP methods). Bodies `{ integrationId }` and `{ integrationId, historyId }` respectively. Permissions (`manage-outgoing-integrations` or `manage-own-outgoing-integrations`) are enforced the same way the DDP methods did. Legacy DDP methods remain registered with deprecation logs pointing at the new routes. diff --git a/.changeset/rest-permissions-add-remove-role.md b/.changeset/rest-permissions-add-remove-role.md new file mode 100644 index 0000000000000..f4a6b916a2724 --- /dev/null +++ b/.changeset/rest-permissions-add-remove-role.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/permissions.addRole` and `POST /v1/permissions.removeRole` (replace the deprecated `authorization:addPermissionToRole` and `authorization:removeRoleFromPermission` DDP methods). Body is `{ permissionId, role }` on both. The same per-user permission checks (`access-permissions`, `access-setting-permissions`) the DDP methods enforced are reused. Legacy DDP methods remain registered with deprecation logs pointing at the new routes. diff --git a/.changeset/rest-rooms-get-by-type-and-name.md b/.changeset/rest-rooms-get-by-type-and-name.md new file mode 100644 index 0000000000000..5e82674b3485b --- /dev/null +++ b/.changeset/rest-rooms-get-by-type-and-name.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `GET /v1/rooms.getByTypeAndName` (replaces the deprecated `getRoomByTypeAndName` DDP method). Query params `{ type, name }`; not auth-gated so anonymous-read flows for public channels keep working (`Accounts_AllowAnonymousRead`). The legacy DDP method remains registered with a deprecation log pointing at the new route. diff --git a/.changeset/rest-users-setavatar-service.md b/.changeset/rest-users-setavatar-service.md new file mode 100644 index 0000000000000..7d4ef483f395f --- /dev/null +++ b/.changeset/rest-users-setavatar-service.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Extended `POST /v1/users.setAvatar` to accept an optional `service` multipart field. When provided, the value is stored as the user's `avatarOrigin`, matching what the deprecated `setAvatarFromService` DDP method did. The legacy DDP method remains registered with a deprecation log pointing at the new route. diff --git a/.changeset/rest-users-verify-email.md b/.changeset/rest-users-verify-email.md new file mode 100644 index 0000000000000..ffca4eac98bff --- /dev/null +++ b/.changeset/rest-users-verify-email.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/users.verifyEmail` (replaces the two-call DDP flow of `verifyEmail` + `afterVerifyEmail`). Body is `{ token }`; the server resolves the user, marks the email verified, and runs the anonymous→user role swap in a single request. The deprecated `afterVerifyEmail` DDP method keeps its registration with a deprecation log pointing at the new route. diff --git a/apps/meteor/app/api/server/v1/cloud.ts b/apps/meteor/app/api/server/v1/cloud.ts index 1338192c00ec5..44a898d18d1c6 100644 --- a/apps/meteor/app/api/server/v1/cloud.ts +++ b/apps/meteor/app/api/server/v1/cloud.ts @@ -1,6 +1,7 @@ import type { CloudRegistrationIntentData, CloudConfirmationPollData, CloudRegistrationStatus } from '@rocket.chat/core-typings'; import { isCloudConfirmationPollProps, + isCloudConnectWorkspaceProps, isCloudCreateRegistrationIntentProps, isCloudManualRegisterProps, ajv, @@ -11,6 +12,7 @@ import { import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; import { SystemLogger } from '../../../../server/lib/logger/system'; +import { connectWorkspace } from '../../../cloud/server/functions/connectWorkspace'; import { getCheckoutUrl } from '../../../cloud/server/functions/getCheckoutUrl'; import { getConfirmationPoll } from '../../../cloud/server/functions/getConfirmationPoll'; import { @@ -287,6 +289,25 @@ declare module '@rocket.chat/rest-typings' { } } +API.v1.post( + 'cloud.connectWorkspace', + { + authRequired: true, + permissionsRequired: ['manage-cloud'], + body: isCloudConnectWorkspaceProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await connectWorkspace(this.bodyParams.token); + return API.v1.success(); + }, +); + API.v1.get( 'cloud.checkoutUrl', { diff --git a/apps/meteor/app/api/server/v1/integrations.ts b/apps/meteor/app/api/server/v1/integrations.ts index 2870fdb8236e8..b8998b622973c 100644 --- a/apps/meteor/app/api/server/v1/integrations.ts +++ b/apps/meteor/app/api/server/v1/integrations.ts @@ -8,6 +8,8 @@ import { isIntegrationsGetProps, isIntegrationsUpdateProps, isIntegrationsListProps, + isIntegrationsClearHistoryProps, + isIntegrationsReplayProps, validateBadRequestErrorResponse, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, @@ -16,6 +18,10 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import type { Filter } from 'mongodb'; +import { + clearIntegrationHistoryMethod, + replayOutgoingIntegrationMethod, +} from '../../../integrations/server/functions/clearIntegrationHistory'; import { mountIntegrationHistoryQueryBasedOnPermissions, mountIntegrationQueryBasedOnPermissions, @@ -363,3 +369,47 @@ API.v1.put( } }, ); + +const voidIntegrationsResponse = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( + 'integrations.history.clear', + { + authRequired: true, + body: isIntegrationsClearHistoryProps, + response: { + 200: voidIntegrationsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await clearIntegrationHistoryMethod(this.userId, this.bodyParams.integrationId); + return API.v1.success(); + }, +); + +API.v1.post( + 'integrations.outgoing.replay', + { + authRequired: true, + body: isIntegrationsReplayProps, + response: { + 200: voidIntegrationsResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { integrationId, historyId } = this.bodyParams; + await replayOutgoingIntegrationMethod(this.userId, { integrationId, historyId }); + return API.v1.success(); + }, +); diff --git a/apps/meteor/app/api/server/v1/permissions.ts b/apps/meteor/app/api/server/v1/permissions.ts index 38c63b1f22a4c..ad289482464a3 100644 --- a/apps/meteor/app/api/server/v1/permissions.ts +++ b/apps/meteor/app/api/server/v1/permissions.ts @@ -9,6 +9,7 @@ import { } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; +import { addPermissionToRoleMethod, removeRoleFromPermissionMethod } from '../../../authorization/server/functions/permissionRole'; import { permissionsGetMethod } from '../../../authorization/server/streamer/permissions'; import { notifyOnPermissionChangedById } from '../../../lib/server/lib/notifyListener'; import type { ExtractRoutesFromAPI } from '../ApiClass'; @@ -62,6 +63,25 @@ const isPermissionsListAll = ajvQuery.compile(permissio const isBodyParamsValidPermissionUpdate = ajv.compile(permissionUpdatePropsSchema); +type PermissionRolePayload = { permissionId: string; role: string }; + +const isPermissionRolePayload = ajv.compile({ + type: 'object', + properties: { + permissionId: { type: 'string', minLength: 1 }, + role: { type: 'string', minLength: 1 }, + }, + required: ['permissionId', 'role'], + additionalProperties: false, +}); + +const voidPermissionResponse = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + const permissionsEndpoints = API.v1 .get( 'permissions.listAll', @@ -185,6 +205,42 @@ const permissionsEndpoints = API.v1 permissions: result, }); }, + ) + .post( + 'permissions.addRole', + { + authRequired: true, + body: isPermissionRolePayload, + response: { + 200: voidPermissionResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { permissionId, role } = this.bodyParams; + await addPermissionToRoleMethod(this.userId, permissionId, role); + return API.v1.success(); + }, + ) + .post( + 'permissions.removeRole', + { + authRequired: true, + body: isPermissionRolePayload, + response: { + 200: voidPermissionResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { permissionId, role } = this.bodyParams; + await removeRoleFromPermissionMethod(this.userId, permissionId, role); + return API.v1.success(); + }, ); export type PermissionsEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index d927487d9b868..05ed38076c418 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -35,6 +35,7 @@ import { isRoomsAutocompleteChannelAndPrivateWithPaginationProps, isRoomsAutocompleteAvailableForTeamsProps, isRoomsSaveRoomSettingsProps, + isRoomsGetByTypeAndNameProps, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, @@ -56,7 +57,7 @@ import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { muteUserInRoom } from '../../../../server/methods/muteUserInRoom'; import { toggleFavoriteMethod } from '../../../../server/methods/toggleFavorite'; import { unmuteUserInRoom } from '../../../../server/methods/unmuteUserInRoom'; -import { roomsGetMethod } from '../../../../server/publications/room'; +import { getRoomByTypeAndNameMethod, roomsGetMethod } from '../../../../server/publications/room'; import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { stripABACManagedFieldsForAdmin } from '../../../authorization/server/lib/isABACManagedRoom'; @@ -555,6 +556,35 @@ API.v1.get( }, ); +API.v1.get( + 'rooms.getByTypeAndName', + { + authRequired: false, + query: isRoomsGetByTypeAndNameProps, + response: { + 200: ajv.compile<{ room: Partial }>({ + type: 'object', + properties: { + room: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { type, name } = this.queryParams; + + const room = await getRoomByTypeAndNameMethod(this.userId ?? null, type, name); + + return API.v1.success({ room }); + }, +); + API.v1.post( 'rooms.createDiscussion', { diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index dc7ed2344be9d..85dc87123c21a 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -38,9 +38,11 @@ import type { Filter } from 'mongodb'; import { generatePersonalAccessTokenOfUser } from '../../../../imports/personal-access-tokens/server/api/methods/generateToken'; import { regeneratePersonalAccessTokenOfUser } from '../../../../imports/personal-access-tokens/server/api/methods/regenerateToken'; import { removePersonalAccessTokenOfUser } from '../../../../imports/personal-access-tokens/server/api/methods/removeToken'; +import { runUserLogoutCleanUp } from '../../../../server/hooks/userLogoutCleanUp'; import { UserChangedAuditStore } from '../../../../server/lib/auditServerEvents/userChanged'; import { i18n } from '../../../../server/lib/i18n'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; +import { runAfterVerifyEmail } from '../../../../server/lib/users/runAfterVerifyEmail'; import { registerUser } from '../../../../server/methods/registerUser'; import { requestDataDownload } from '../../../../server/methods/requestDataDownload'; import { resetAvatar } from '../../../../server/methods/resetAvatar'; @@ -343,7 +345,9 @@ API.v1 } } - await setUserAvatar(user, fileBuffer, mimetype, 'rest'); + const service = typeof fields.service === 'string' && fields.service.length > 0 ? fields.service : 'rest'; + + await setUserAvatar(user, fileBuffer, mimetype, service); return API.v1.success(); }, @@ -1880,6 +1884,8 @@ API.v1 return API.v1.forbidden(); } + const user = await Users.findOneById(userId); + // this method logs the user out automatically, if successful returns 1, otherwise 0 if (!(await Users.unsetLoginTokens(userId))) { throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); @@ -1889,6 +1895,10 @@ API.v1 void notifyOnUserChange({ clientAction: 'updated', id: userId, diff: { 'services.resume.loginTokens': [] } }); + if (user) { + await runUserLogoutCleanUp(user); + } + return API.v1.success({ message: `User ${userId} has been logged out!`, }); @@ -2088,6 +2098,38 @@ API.v1 }, ); +API.v1.post( + 'users.verifyEmail', + { + authRequired: false, + body: ajv.compile<{ token: string }>({ + type: 'object', + properties: { + token: { type: 'string', minLength: 1 }, + }, + required: ['token'], + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { token } = this.bodyParams; + + const user = await Users.findOne>({ 'services.email.verificationTokens.token': token }, { projection: { _id: 1 } }); + + await Meteor.callAsync('verifyEmail', token); + + if (user) { + await runAfterVerifyEmail(user._id); + } + + return API.v1.success(); + }, +); + settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { const userRegisterRoute = '/api/v1/users.registerpost'; diff --git a/apps/meteor/app/authorization/server/functions/permissionRole.ts b/apps/meteor/app/authorization/server/functions/permissionRole.ts new file mode 100644 index 0000000000000..f5ed30bc133d0 --- /dev/null +++ b/apps/meteor/app/authorization/server/functions/permissionRole.ts @@ -0,0 +1,78 @@ +import { License } from '@rocket.chat/core-services'; +import { Permissions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from './hasPermission'; +import { notifyOnPermissionChangedById } from '../../../lib/server/lib/notifyListener'; +import { CONSTANTS, AuthorizationUtils } from '../../lib'; + +export const addPermissionToRoleMethod = async (uid: string | null, permissionId: string, role: string): Promise => { + if (role === 'guest' && !AuthorizationUtils.hasRestrictionsToRole(role) && (await License.hasValidLicense())) { + AuthorizationUtils.addRolePermissionWhiteList(role, await License.getGuestPermissions()); + } + + if (AuthorizationUtils.isPermissionRestrictedForRole(permissionId, role)) { + throw new Meteor.Error('error-action-not-allowed', 'Permission is restricted', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } + + const permission = await Permissions.findOneById(permissionId); + + if (!permission) { + throw new Meteor.Error('error-invalid-permission', 'Permission does not exist', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } + + if ( + !uid || + !(await hasPermissionAsync(uid, 'access-permissions')) || + (permission.level === CONSTANTS.SETTINGS_LEVEL && !(await hasPermissionAsync(uid, 'access-setting-permissions'))) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Adding permission is not allowed', { + method: 'authorization:addPermissionToRole', + action: 'Adding_permission', + }); + } + + if (permission.groupPermissionId) { + await Permissions.addRole(permission.groupPermissionId, role); + void notifyOnPermissionChangedById(permission.groupPermissionId); + } + + await Permissions.addRole(permission._id, role); + + void notifyOnPermissionChangedById(permission._id); +}; + +export const removeRoleFromPermissionMethod = async (uid: string | null, permissionId: string, role: string): Promise => { + const permission = await Permissions.findOneById(permissionId); + + if (!permission) { + throw new Meteor.Error('error-permission-not-found', 'Permission not found', { + method: 'authorization:removeRoleFromPermission', + }); + } + + if ( + !uid || + !(await hasPermissionAsync(uid, 'access-permissions')) || + (permission.level === CONSTANTS.SETTINGS_LEVEL && !(await hasPermissionAsync(uid, 'access-setting-permissions'))) + ) { + throw new Meteor.Error('error-action-not-allowed', 'Removing permission is not allowed', { + method: 'authorization:removeRoleFromPermission', + action: 'Removing_permission', + }); + } + + if (permission.groupPermissionId) { + await Permissions.removeRole(permission.groupPermissionId, role); + void notifyOnPermissionChangedById(permission.groupPermissionId); + } + + await Permissions.removeRole(permission._id, role); + void notifyOnPermissionChangedById(permission._id); +}; diff --git a/apps/meteor/app/authorization/server/methods/addPermissionToRole.ts b/apps/meteor/app/authorization/server/methods/addPermissionToRole.ts index 3f5f7bfc1a263..2e8f2a66af9bf 100644 --- a/apps/meteor/app/authorization/server/methods/addPermissionToRole.ts +++ b/apps/meteor/app/authorization/server/methods/addPermissionToRole.ts @@ -1,11 +1,8 @@ -import { License } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Permissions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { notifyOnPermissionChangedById } from '../../../lib/server/lib/notifyListener'; -import { CONSTANTS, AuthorizationUtils } from '../../lib'; -import { hasPermissionAsync } from '../functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +import { addPermissionToRoleMethod } from '../functions/permissionRole'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -16,46 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'authorization:addPermissionToRole'(permissionId, role) { - if (role === 'guest' && !AuthorizationUtils.hasRestrictionsToRole(role) && (await License.hasValidLicense())) { - AuthorizationUtils.addRolePermissionWhiteList(role, await License.getGuestPermissions()); - } - - if (AuthorizationUtils.isPermissionRestrictedForRole(permissionId, role)) { - throw new Meteor.Error('error-action-not-allowed', 'Permission is restricted', { - method: 'authorization:addPermissionToRole', - action: 'Adding_permission', - }); - } - - const uid = Meteor.userId(); - const permission = await Permissions.findOneById(permissionId); - - if (!permission) { - throw new Meteor.Error('error-invalid-permission', 'Permission does not exist', { - method: 'authorization:addPermissionToRole', - action: 'Adding_permission', - }); - } - - if ( - !uid || - !(await hasPermissionAsync(uid, 'access-permissions')) || - (permission.level === CONSTANTS.SETTINGS_LEVEL && !(await hasPermissionAsync(uid, 'access-setting-permissions'))) - ) { - throw new Meteor.Error('error-action-not-allowed', 'Adding permission is not allowed', { - method: 'authorization:addPermissionToRole', - action: 'Adding_permission', - }); - } - - // for setting-based-permissions, authorize the group access as well - if (permission.groupPermissionId) { - await Permissions.addRole(permission.groupPermissionId, role); - void notifyOnPermissionChangedById(permission.groupPermissionId); - } - - await Permissions.addRole(permission._id, role); - - void notifyOnPermissionChangedById(permission._id); + methodDeprecationLogger.method('authorization:addPermissionToRole', '9.0.0', '/v1/permissions.addRole'); + await addPermissionToRoleMethod(Meteor.userId(), permissionId, role); }, }); diff --git a/apps/meteor/app/authorization/server/methods/removeRoleFromPermission.ts b/apps/meteor/app/authorization/server/methods/removeRoleFromPermission.ts index 68ca11ef9fb5f..2a1d53933b208 100644 --- a/apps/meteor/app/authorization/server/methods/removeRoleFromPermission.ts +++ b/apps/meteor/app/authorization/server/methods/removeRoleFromPermission.ts @@ -1,10 +1,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Permissions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { notifyOnPermissionChangedById } from '../../../lib/server/lib/notifyListener'; -import { CONSTANTS } from '../../lib'; -import { hasPermissionAsync } from '../functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +import { removeRoleFromPermissionMethod } from '../functions/permissionRole'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,34 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'authorization:removeRoleFromPermission'(permissionId, role) { - const uid = Meteor.userId(); - const permission = await Permissions.findOneById(permissionId); - - if (!permission) { - throw new Meteor.Error('error-permission-not-found', 'Permission not found', { - method: 'authorization:removeRoleFromPermission', - }); - } - - if ( - !uid || - !(await hasPermissionAsync(uid, 'access-permissions')) || - (permission.level === CONSTANTS.SETTINGS_LEVEL && !(await hasPermissionAsync(uid, 'access-setting-permissions'))) - ) { - throw new Meteor.Error('error-action-not-allowed', 'Removing permission is not allowed', { - method: 'authorization:removeRoleFromPermission', - action: 'Removing_permission', - }); - } - - // for setting based permissions, revoke the group permission once all setting permissions - // related to this group have been removed - if (permission.groupPermissionId) { - await Permissions.removeRole(permission.groupPermissionId, role); - void notifyOnPermissionChangedById(permission.groupPermissionId); - } - - await Permissions.removeRole(permission._id, role); - void notifyOnPermissionChangedById(permission._id); + methodDeprecationLogger.method('authorization:removeRoleFromPermission', '9.0.0', '/v1/permissions.removeRole'); + await removeRoleFromPermissionMethod(Meteor.userId(), permissionId, role); }, }); diff --git a/apps/meteor/app/cloud/server/methods.ts b/apps/meteor/app/cloud/server/methods.ts index ef6e6b34927ea..9dccd0fe013a9 100644 --- a/apps/meteor/app/cloud/server/methods.ts +++ b/apps/meteor/app/cloud/server/methods.ts @@ -112,6 +112,7 @@ Meteor.methods({ return true; }, async 'cloud:connectWorkspace'(token) { + methodDeprecationLogger.method('cloud:connectWorkspace', '9.0.0', '/v1/cloud.connectWorkspace'); check(token, String); const uid = Meteor.userId(); diff --git a/apps/meteor/app/integrations/server/functions/clearIntegrationHistory.ts b/apps/meteor/app/integrations/server/functions/clearIntegrationHistory.ts new file mode 100644 index 0000000000000..34b2c63508e2c --- /dev/null +++ b/apps/meteor/app/integrations/server/functions/clearIntegrationHistory.ts @@ -0,0 +1,70 @@ +import type { IOutgoingIntegration, IIntegration } from '@rocket.chat/core-typings'; +import { Integrations, IntegrationHistory } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import notifications from '../../../notifications/server/lib/Notifications'; +import { triggerHandler } from '../lib/triggerHandler'; + +export const clearIntegrationHistoryMethod = async (userId: string | null, integrationId: string): Promise => { + if (!userId) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'clearIntegrationHistory' }); + } + + let integration: IIntegration | null = null; + + if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) { + integration = await Integrations.findOneById(integrationId); + } else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) { + integration = await Integrations.findOne({ + '_id': integrationId, + '_createdBy._id': userId, + }); + } else { + throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'clearIntegrationHistory' }); + } + + if (!integration) { + throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'clearIntegrationHistory' }); + } + + await IntegrationHistory.removeByIntegrationId(integrationId); + + notifications.streamIntegrationHistory.emit(integrationId, { type: 'removed', id: integrationId }); +}; + +export const replayOutgoingIntegrationMethod = async ( + userId: string | null, + { integrationId, historyId }: { integrationId: string; historyId: string }, +): Promise => { + if (!userId) { + throw new Meteor.Error('not_authorized', 'Unauthorized', { method: 'replayOutgoingIntegration' }); + } + + let integration: IOutgoingIntegration | null = null; + + if (await hasPermissionAsync(userId, 'manage-outgoing-integrations')) { + integration = await Integrations.findOneById(integrationId); + } else if (await hasPermissionAsync(userId, 'manage-own-outgoing-integrations')) { + const found = await Integrations.findOne({ + '_id': integrationId, + '_createdBy._id': userId, + }); + + if (found && 'event' in found) { + integration = found; + } + } + + if (!integration) { + throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { method: 'replayOutgoingIntegration' }); + } + + const history = await IntegrationHistory.findOneByIntegrationIdAndHistoryId(integration._id, historyId); + + if (!history) { + throw new Meteor.Error('error-invalid-integration-history', 'Invalid Integration History', { method: 'replayOutgoingIntegration' }); + } + + await triggerHandler.replay(integration, history); +}; diff --git a/apps/meteor/app/integrations/server/methods/clearIntegrationHistory.ts b/apps/meteor/app/integrations/server/methods/clearIntegrationHistory.ts index a30bb9b5ee9a3..93e3bf7b4c320 100644 --- a/apps/meteor/app/integrations/server/methods/clearIntegrationHistory.ts +++ b/apps/meteor/app/integrations/server/methods/clearIntegrationHistory.ts @@ -1,9 +1,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Integrations, IntegrationHistory } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import notifications from '../../../notifications/server/lib/Notifications'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +import { clearIntegrationHistoryMethod } from '../functions/clearIntegrationHistory'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,38 +13,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async clearIntegrationHistory(integrationId) { - let integration; - - if (!this.userId) { - throw new Meteor.Error('not_authorized', 'Unauthorized', { - method: 'clearIntegrationHistory', - }); - } - - if (await hasPermissionAsync(this.userId, 'manage-outgoing-integrations')) { - integration = await Integrations.findOneById(integrationId); - } else if (await hasPermissionAsync(this.userId, 'manage-own-outgoing-integrations')) { - integration = await Integrations.findOne({ - '_id': integrationId, - '_createdBy._id': this.userId, - }); - } else { - throw new Meteor.Error('not_authorized', 'Unauthorized', { - method: 'clearIntegrationHistory', - }); - } - - if (!integration) { - throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { - method: 'clearIntegrationHistory', - }); - } - - // Don't sending to IntegrationHistory listener since it don't waits for 'removed' events. - await IntegrationHistory.removeByIntegrationId(integrationId); - - notifications.streamIntegrationHistory.emit(integrationId, { type: 'removed', id: integrationId }); - + methodDeprecationLogger.method('clearIntegrationHistory', '9.0.0', '/v1/integrations.history.clear'); + await clearIntegrationHistoryMethod(this.userId, integrationId); return true; }, }); diff --git a/apps/meteor/app/integrations/server/methods/outgoing/replayOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/replayOutgoingIntegration.ts index c8108bfc2acda..0176e1a59e7ba 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/replayOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/replayOutgoingIntegration.ts @@ -1,10 +1,8 @@ -import type { IOutgoingIntegration } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Integrations, IntegrationHistory } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; -import { triggerHandler } from '../../lib/triggerHandler'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; +import { replayOutgoingIntegrationMethod } from '../../functions/clearIntegrationHistory'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,43 +13,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async replayOutgoingIntegration({ integrationId, historyId }) { - let integration: IOutgoingIntegration | null = null; - - if (!this.userId) { - throw new Meteor.Error('not_authorized', 'Unauthorized', { - method: 'replayOutgoingIntegration', - }); - } - - if (await hasPermissionAsync(this.userId, 'manage-outgoing-integrations')) { - integration = await Integrations.findOneById(integrationId); - } else if (await hasPermissionAsync(this.userId, 'manage-own-outgoing-integrations')) { - const foundIntegration = await Integrations.findOne({ - '_id': integrationId, - '_createdBy._id': this.userId, - }); - - if (foundIntegration && 'event' in foundIntegration) { - integration = foundIntegration; - } - } - - if (!integration) { - throw new Meteor.Error('error-invalid-integration', 'Invalid integration', { - method: 'replayOutgoingIntegration', - }); - } - - const history = await IntegrationHistory.findOneByIntegrationIdAndHistoryId(integration._id, historyId); - - if (!history) { - throw new Meteor.Error('error-invalid-integration-history', 'Invalid Integration History', { - method: 'replayOutgoingIntegration', - }); - } - - await triggerHandler.replay(integration, history); - + methodDeprecationLogger.method('replayOutgoingIntegration', '9.0.0', '/v1/integrations.outgoing.replay'); + await replayOutgoingIntegrationMethod(this.userId, { integrationId, historyId }); return true; }, }); diff --git a/apps/meteor/client/hooks/useUpdateAvatar.ts b/apps/meteor/client/hooks/useUpdateAvatar.ts index d8a122683215a..0a03f1b267d18 100644 --- a/apps/meteor/client/hooks/useUpdateAvatar.ts +++ b/apps/meteor/client/hooks/useUpdateAvatar.ts @@ -1,5 +1,5 @@ import type { AvatarObject, AvatarServiceObject, AvatarReset, AvatarUrlObj, IUser } from '@rocket.chat/core-typings'; -import { useToastMessageDispatch, useMethod } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -17,7 +17,6 @@ export const useUpdateAvatar = (avatarObj: AvatarObject, userId: IUser['_id']) = const avatarUrl = isAvatarUrl(avatarObj) ? avatarObj.avatarUrl : ''; const successMessage = t('Avatar_changed_successfully'); - const setAvatarFromService = useMethod('setAvatarFromService'); const dispatchToastMessage = useToastMessageDispatch(); @@ -54,30 +53,19 @@ export const useUpdateAvatar = (avatarObj: AvatarObject, userId: IUser['_id']) = } if (isServiceObject(avatarObj)) { - const { blob, contentType, service } = avatarObj; - try { - await setAvatarFromService(blob, contentType, service); - dispatchToastMessage({ type: 'success', message: successMessage }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } + const { blob, service } = avatarObj; + const formData = new FormData(); + formData.append('userId', userId); + formData.append('service', service); + formData.append('image', blob); + await saveAvatarAction(formData); return; } if (avatarObj instanceof FormData) { avatarObj.set('userId', userId); await saveAvatarAction(avatarObj); } - }, [ - avatarObj, - avatarUrl, - dispatchToastMessage, - resetAvatarAction, - saveAvatarAction, - saveAvatarUrlAction, - setAvatarFromService, - successMessage, - userId, - ]); + }, [avatarObj, avatarUrl, resetAvatarAction, saveAvatarAction, saveAvatarUrlAction, successMessage, userId]); return updateAvatar; }; diff --git a/apps/meteor/client/meteor/startup/accounts.ts b/apps/meteor/client/meteor/startup/accounts.ts index 25be6aac9e006..67220ff7b6d6e 100644 --- a/apps/meteor/client/meteor/startup/accounts.ts +++ b/apps/meteor/client/meteor/startup/accounts.ts @@ -48,9 +48,8 @@ const whenMainReady = (): Promise => { getDdpSdk().account.onEmailVerificationLink(async (token: string) => { try { - await sdk.call('verifyEmail', token); + await sdk.rest.post('/v1/users.verifyEmail', { token }); await whenMainReady(); - void sdk.call('afterVerifyEmail'); dispatchToastMessage({ type: 'success', message: t('Email_verified') }); } catch (error) { await whenMainReady(); diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index c1e8b84aff014..c528dbc2383df 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -1,5 +1,4 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; import type { FindOptions, SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; @@ -16,11 +15,9 @@ import { useDeleteUser } from './hooks/useDeleteUser'; import { useEmailVerificationWarning } from './hooks/useEmailVerificationWarning'; import { useReloadAfterLogin } from './hooks/useReloadAfterLogin'; import { useUpdateAvatar } from './hooks/useUpdateAvatar'; -import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { useIdleConnection } from '../../hooks/useIdleConnection'; import type { IDocumentMapStore } from '../../lib/cachedStores/DocumentMapStore'; import { applyQueryOptions } from '../../lib/cachedStores/applyQueryOptions'; -import { getDdpSdk } from '../../lib/sdk/ddpSdk'; import { settings } from '../../lib/settings'; import { userIdStore } from '../../lib/user'; import { Users, Rooms, Subscriptions } from '../../stores'; @@ -30,17 +27,10 @@ type UserProviderProps = { children: ReactNode; }; -const ee = new Emitter(); -getDdpSdk().account.onLogout(() => ee.emit('logout')); - -ee.on('logout', async () => { - const userId = userIdStore.getState(); - if (!userId) return; - const user = Users.state.get(userId); - if (!user) return; - - await sdk.call('logoutCleanUp', user); -}); +// `afterLogoutCleanUpCallback` and `Apps.IPostUserLoggedOut` are now fired +// server-side by the `Accounts.onLogout` hook (and by `/v1/users.logout` +// after token invalidation), so the client does not need to make a round +// trip to invoke them. const queryRoom = ( query: Filter>, diff --git a/apps/meteor/client/views/admin/integrations/outgoing/history/HistoryItem.tsx b/apps/meteor/client/views/admin/integrations/outgoing/history/HistoryItem.tsx index 74327d4968bf3..1bbcd0e54dfe7 100644 --- a/apps/meteor/client/views/admin/integrations/outgoing/history/HistoryItem.tsx +++ b/apps/meteor/client/views/admin/integrations/outgoing/history/HistoryItem.tsx @@ -1,7 +1,7 @@ import type { IIntegrationHistory, Serialized } from '@rocket.chat/core-typings'; import { Button, Icon, Box, AccordionItem, Field, FieldGroup, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useMethod } from '@rocket.chat/ui-contexts'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; import DOMPurify from 'dompurify'; import type { MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,7 +13,7 @@ import { useHighlightedCode } from '../../../../../hooks/useHighlightedCode'; const HistoryItem = ({ data }: { data: Serialized }) => { const { t } = useTranslation(); - const replayOutgoingIntegration = useMethod('replayOutgoingIntegration'); + const replayOutgoingIntegration = useEndpoint('POST', '/v1/integrations.outgoing.replay'); const { _id, diff --git a/apps/meteor/client/views/admin/integrations/outgoing/history/OutgoingWebhookHistoryPage.tsx b/apps/meteor/client/views/admin/integrations/outgoing/history/OutgoingWebhookHistoryPage.tsx index 2784d26eb6c48..1452cacf54613 100644 --- a/apps/meteor/client/views/admin/integrations/outgoing/history/OutgoingWebhookHistoryPage.tsx +++ b/apps/meteor/client/views/admin/integrations/outgoing/history/OutgoingWebhookHistoryPage.tsx @@ -1,6 +1,6 @@ import { Button, ButtonGroup, Pagination } from '@rocket.chat/fuselage'; import { CustomScrollbars, usePagination, Page, PageHeader, PageContent } from '@rocket.chat/ui-client'; -import { useToastMessageDispatch, useRouteParameter, useMethod, useTranslation, useEndpoint, useRouter } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useRouteParameter, useTranslation, useEndpoint, useRouter } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { ComponentProps } from 'react'; import { useMemo, useState, useEffect } from 'react'; @@ -18,7 +18,7 @@ const OutgoingWebhookHistoryPage = (props: ComponentProps) => { const [mounted, setMounted] = useState(false); const [total, setTotal] = useState(0); - const clearIntegrationHistory = useMethod('clearIntegrationHistory'); + const clearIntegrationHistory = useEndpoint('POST', '/v1/integrations.history.clear'); const id = useRouteParameter('id') as string; @@ -52,7 +52,7 @@ const OutgoingWebhookHistoryPage = (props: ComponentProps) => { const handleClearHistory = async (): Promise => { try { - await clearIntegrationHistory(id); + await clearIntegrationHistory({ integrationId: id }); dispatchToastMessage({ type: 'success', message: t('Integration_History_Cleared') }); refetch(); setMounted(false); diff --git a/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx index 64171fb39e39a..7be98ee3886a3 100644 --- a/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx @@ -3,7 +3,8 @@ import { css } from '@rocket.chat/css-in-js'; import { Pagination, Palette } from '@rocket.chat/fuselage'; import { GenericTable, GenericTableHeader, GenericTableHeaderCell, GenericTableBody } from '@rocket.chat/ui-client'; import type { usePagination } from '@rocket.chat/ui-client'; -import { useMethod } from '@rocket.chat/ui-contexts'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import PermissionRow from './PermissionRow'; @@ -22,8 +23,22 @@ type PermissionsTableProps = { const PermissionsTable = ({ roleList, permissions, setFilter, total, paginationData }: PermissionsTableProps) => { const { t } = useTranslation(); - const grantRole = useMethod('authorization:addPermissionToRole'); - const removeRole = useMethod('authorization:removeRoleFromPermission'); + const addPermissionRole = useEndpoint('POST', '/v1/permissions.addRole'); + const removePermissionRole = useEndpoint('POST', '/v1/permissions.removeRole'); + + const grantRole = useCallback( + async (permissionId: IPermission['_id'], role: IRole['_id']) => { + await addPermissionRole({ permissionId, role }); + }, + [addPermissionRole], + ); + + const removeRole = useCallback( + async (permissionId: IPermission['_id'], role: IRole['_id']) => { + await removePermissionRole({ permissionId, role }); + }, + [removePermissionRole], + ); const { current, itemsPerPage, setCurrent, setItemsPerPage, ...paginationProps } = paginationData; diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisterWorkspaceTokenModal.tsx b/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisterWorkspaceTokenModal.tsx index 9d156e965991a..d3de17a9ebbcb 100644 --- a/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisterWorkspaceTokenModal.tsx +++ b/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisterWorkspaceTokenModal.tsx @@ -15,7 +15,7 @@ import { ModalContent, ModalFooter, } from '@rocket.chat/fuselage'; -import { useMethod, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { ChangeEvent } from 'react'; import { useState } from 'react'; import { useTranslation, Trans } from 'react-i18next'; @@ -31,7 +31,7 @@ const RegisterWorkspaceTokenModal = ({ onClose, onStatusChange, ...props }: Regi const setModal = useSetModal(); const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); - const connectWorkspace = useMethod('cloud:connectWorkspace'); + const connectWorkspace = useEndpoint('POST', '/v1/cloud.connectWorkspace'); const [token, setToken] = useState(''); const [processing, setProcessing] = useState(false); @@ -53,11 +53,7 @@ const RegisterWorkspaceTokenModal = ({ onClose, onStatusChange, ...props }: Regi setError(false); try { - const isConnected = await connectWorkspace(token); - - if (!isConnected) { - throw Error(t('RegisterWorkspace_Connection_Error')); - } + await connectWorkspace({ token }); setModal(null); diff --git a/apps/meteor/client/views/room/hooks/useOpenRoom.ts b/apps/meteor/client/views/room/hooks/useOpenRoom.ts index 76dd6d9cc2d18..6028e91cb1a6b 100644 --- a/apps/meteor/client/views/room/hooks/useOpenRoom.ts +++ b/apps/meteor/client/views/room/hooks/useOpenRoom.ts @@ -1,6 +1,6 @@ import { isPublicRoom, type IRoom, type RoomType } from '@rocket.chat/core-typings'; import { getObjectKeys } from '@rocket.chat/tools'; -import { useEndpoint, useMethod, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts'; +import { useEndpoint, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect } from 'react'; @@ -19,7 +19,7 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st const user = useUser(); const hasPreviewPermission = usePermission('preview-c-room'); const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', true); - const getRoomByTypeAndName = useMethod('getRoomByTypeAndName'); + const getRoomByTypeAndName = useEndpoint('GET', '/v1/rooms.getByTypeAndName'); const createDirectMessage = useEndpoint('POST', '/v1/im.create'); const directRoute = useRoute('direct'); const openRoom = useOpenRoomMutation(); @@ -76,7 +76,7 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st let roomData: IRoom; try { - roomData = await getRoomByTypeAndName(type, reference); + roomData = (await getRoomByTypeAndName({ type, name: reference })).room as IRoom; } catch (error) { if (type !== 'd') { throw new RoomNotFoundError(undefined, { type, reference }); diff --git a/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx b/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx index 35edbf4e78ee1..90f099417f594 100644 --- a/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx +++ b/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx @@ -1,4 +1,4 @@ -import { useEndpoint, useMethod, useRouter, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useRouter, useUserId } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement, ReactNode } from 'react'; import { useEffect, useMemo } from 'react'; @@ -34,7 +34,7 @@ const EmbeddedPreload = ({ children }: { children: ReactNode }): ReactElement => return directives.extractOpenRoomParams(router.getRouteParameters()); }, [router]); - const getRoomByTypeAndName = useMethod('getRoomByTypeAndName'); + const getRoomByTypeAndName = useEndpoint('GET', '/v1/rooms.getByTypeAndName'); const getSubscription = useEndpoint('GET', '/v1/subscriptions.getOne'); const shouldFetch = !!roomParams && !!uid; @@ -46,7 +46,7 @@ const EmbeddedPreload = ({ children }: { children: ReactNode }): ReactElement => return null; } - const roomData = await getRoomByTypeAndName(roomParams.type, roomParams.reference); + const { room: roomData } = await getRoomByTypeAndName({ type: roomParams.type, name: roomParams.reference }); if (!roomData?._id) { return null; } diff --git a/apps/meteor/server/hooks/index.ts b/apps/meteor/server/hooks/index.ts index 64d500b223f25..1a9f55034ccbd 100644 --- a/apps/meteor/server/hooks/index.ts +++ b/apps/meteor/server/hooks/index.ts @@ -1 +1,2 @@ import './sauMonitorHooks'; +import './userLogoutCleanUp'; diff --git a/apps/meteor/server/hooks/userLogoutCleanUp.ts b/apps/meteor/server/hooks/userLogoutCleanUp.ts new file mode 100644 index 0000000000000..c725cdb6c61ee --- /dev/null +++ b/apps/meteor/server/hooks/userLogoutCleanUp.ts @@ -0,0 +1,21 @@ +import { AppEvents, Apps } from '@rocket.chat/apps'; +import type { IUser } from '@rocket.chat/core-typings'; +import { Accounts } from 'meteor/accounts-base'; + +import { afterLogoutCleanUpCallback } from '../lib/callbacks/afterLogoutCleanUpCallback'; + +export const runUserLogoutCleanUp = async (user: IUser): Promise => { + setImmediate(() => { + void afterLogoutCleanUpCallback.run(user); + }); + + await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedOut, user); +}; + +Accounts.onLogout(async ({ user }: { user?: IUser }) => { + if (!user) { + return; + } + + await runUserLogoutCleanUp(user); +}); diff --git a/apps/meteor/server/lib/users/runAfterVerifyEmail.ts b/apps/meteor/server/lib/users/runAfterVerifyEmail.ts new file mode 100644 index 0000000000000..f2bf9ef2a33af --- /dev/null +++ b/apps/meteor/server/lib/users/runAfterVerifyEmail.ts @@ -0,0 +1,31 @@ +import type { IRole, IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { addUserRolesAsync } from '../roles/addUserRoles'; +import { removeUserFromRolesAsync } from '../roles/removeUserFromRoles'; + +const rolesToChangeTo: Map = new Map([['anonymous', ['user']]]); + +export const runAfterVerifyEmail = async (userId: IUser['_id']): Promise => { + const user = await Users.findOneById(userId); + if (!user?.emails || !Array.isArray(user.emails)) { + return; + } + + const verifiedEmail = user.emails.find((email) => email.verified); + if (!verifiedEmail) { + return; + } + + const rolesThatNeedChanges = user.roles.filter((role) => rolesToChangeTo.has(role)); + + await Promise.all( + rolesThatNeedChanges.map(async (role) => { + const rolesToAdd = rolesToChangeTo.get(role); + if (rolesToAdd) { + await addUserRolesAsync(userId, rolesToAdd); + } + await removeUserFromRolesAsync(user._id, [role]); + }), + ); +}; diff --git a/apps/meteor/server/methods/afterVerifyEmail.ts b/apps/meteor/server/methods/afterVerifyEmail.ts index 9e1589c1895a8..f8e5cdb16babe 100644 --- a/apps/meteor/server/methods/afterVerifyEmail.ts +++ b/apps/meteor/server/methods/afterVerifyEmail.ts @@ -1,12 +1,8 @@ -import type { IRole } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { addUserRolesAsync } from '../lib/roles/addUserRoles'; -import { removeUserFromRolesAsync } from '../lib/roles/removeUserFromRoles'; - -const rolesToChangeTo: Map = new Map([['anonymous', ['user']]]); +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; +import { runAfterVerifyEmail } from '../lib/users/runAfterVerifyEmail'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -17,6 +13,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async afterVerifyEmail() { + methodDeprecationLogger.method('afterVerifyEmail', '9.0.0', '/v1/users.verifyEmail'); + const userId = Meteor.userId(); if (!userId) { @@ -25,23 +23,6 @@ Meteor.methods({ }); } - const user = await Users.findOneById(userId); - if (user?.emails && Array.isArray(user.emails)) { - const verifiedEmail = user.emails.find((email) => email.verified); - - const rolesThatNeedChanges = user.roles.filter((role) => rolesToChangeTo.has(role)); - - if (verifiedEmail) { - await Promise.all( - rolesThatNeedChanges.map(async (role) => { - const rolesToAdd = rolesToChangeTo.get(role); - if (rolesToAdd) { - await addUserRolesAsync(userId, rolesToAdd); - } - await removeUserFromRolesAsync(user._id, [role]); - }), - ); - } - } + await runAfterVerifyEmail(userId); }, }); diff --git a/apps/meteor/server/methods/logoutCleanUp.ts b/apps/meteor/server/methods/logoutCleanUp.ts index 396311161d7f7..02c3b4ffc0226 100644 --- a/apps/meteor/server/methods/logoutCleanUp.ts +++ b/apps/meteor/server/methods/logoutCleanUp.ts @@ -1,10 +1,10 @@ -import { AppEvents, Apps } from '@rocket.chat/apps'; import type { IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { afterLogoutCleanUpCallback } from '../lib/callbacks/afterLogoutCleanUpCallback'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; +import { runUserLogoutCleanUp } from '../hooks/userLogoutCleanUp'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,13 +15,9 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async logoutCleanUp(user) { + methodDeprecationLogger.method('logoutCleanUp', '9.0.0', '/v1/users.logout'); check(user, Object); - setImmediate(() => { - void afterLogoutCleanUpCallback.run(user); - }); - - // App IPostUserLogout event hook - await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedOut, user); + await runUserLogoutCleanUp(user); }, }); diff --git a/apps/meteor/server/publications/room/index.ts b/apps/meteor/server/publications/room/index.ts index 9d143a696aca4..d6004c5bfc777 100644 --- a/apps/meteor/server/publications/room/index.ts +++ b/apps/meteor/server/publications/room/index.ts @@ -1,11 +1,12 @@ import type { IOmnichannelRoom, IRoom, RoomType } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Rooms } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { canAccessRoomAsync } from '../../../app/authorization/server'; import { hasPermissionAsync } from '../../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../app/settings/server'; import { roomFields } from '../../../lib/publishFields'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; @@ -45,55 +46,60 @@ export const roomsGetMethod = async (userId?: string | null, updatedAt?: Date): return (await Rooms.findBySubscriptionUserId(userId, options)).toArray(); }; -Meteor.methods({ - async 'rooms/get'(updatedAt) { - return roomsGetMethod(Meteor.userId(), updatedAt); - }, +export const getRoomByTypeAndNameMethod = async (userId: string | null, type: RoomType, name: string): Promise => { + if (!type || !name) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'getRoomByTypeAndName', + }); + } - async 'getRoomByTypeAndName'(type, name) { - if (!type || !name) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { + const user = userId ? await Users.findOneById(userId) : null; + const isAnonymous = !user?._id; + + if (isAnonymous) { + const allowAnon = settings.get('Accounts_AllowAnonymousRead'); + if (!allowAnon || type !== 'c') { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getRoomByTypeAndName', }); } + } - const user = await Meteor.userAsync(); - const isAnonymous = !user?._id; + const roomFind = roomCoordinator.getRoomFind(type); - if (isAnonymous) { - const allowAnon = settings.get('Accounts_AllowAnonymousRead'); - if (!allowAnon || type !== 'c') { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'getRoomByTypeAndName', - }); - } - } + const room = roomFind ? await roomFind.call({}, name) : await Rooms.findByTypeAndNameOrId(type, name); - const roomFind = roomCoordinator.getRoomFind(type); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'getRoomByTypeAndName', + }); + } - const room = roomFind ? await roomFind.call(this, name) : await Rooms.findByTypeAndNameOrId(type, name); + if ( + user && + !(await canAccessRoomAsync(room, user, { + includeInvitations: true, + })) + ) { + throw new Meteor.Error('error-no-permission', 'No permission', { + method: 'getRoomByTypeAndName', + }); + } - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'getRoomByTypeAndName', - }); - } + if (settings.get('Store_Last_Message') && user && !(await hasPermissionAsync(user._id, 'preview-c-room'))) { + delete room.lastMessage; + } - if ( - user && - !(await canAccessRoomAsync(room, user, { - includeInvitations: true, - })) - ) { - throw new Meteor.Error('error-no-permission', 'No permission', { - method: 'getRoomByTypeAndName', - }); - } + return roomMap(room); +}; - if (settings.get('Store_Last_Message') && user && !(await hasPermissionAsync(user._id, 'preview-c-room'))) { - delete room.lastMessage; - } +Meteor.methods({ + async 'rooms/get'(updatedAt) { + return roomsGetMethod(Meteor.userId(), updatedAt); + }, - return roomMap(room); + async 'getRoomByTypeAndName'(type, name) { + methodDeprecationLogger.method('getRoomByTypeAndName', '9.0.0', '/v1/rooms.getByTypeAndName'); + return getRoomByTypeAndNameMethod(Meteor.userId(), type, name); }, }); diff --git a/packages/rest-typings/src/v1/cloud.ts b/packages/rest-typings/src/v1/cloud.ts index 435beb8739e3c..45d4716b1bdf6 100644 --- a/packages/rest-typings/src/v1/cloud.ts +++ b/packages/rest-typings/src/v1/cloud.ts @@ -59,6 +59,22 @@ const CloudConfirmationPollSchema = { export const isCloudConfirmationPollProps = ajvQuery.compile(CloudConfirmationPollSchema); +type CloudConnectWorkspace = { token: string }; + +const CloudConnectWorkspaceSchema = { + type: 'object', + properties: { + token: { + type: 'string', + minLength: 1, + }, + }, + required: ['token'], + additionalProperties: false, +}; + +export const isCloudConnectWorkspaceProps = ajv.compile(CloudConnectWorkspaceSchema); + export type CloudEndpoints = { '/v1/cloud.manualRegister': { POST: (params: CloudManualRegister) => void; @@ -87,4 +103,7 @@ export type CloudEndpoints = { '/v1/cloud.removeLicense': { POST: () => { success: boolean }; }; + '/v1/cloud.connectWorkspace': { + POST: (params: CloudConnectWorkspace) => { success: boolean }; + }; }; diff --git a/packages/rest-typings/src/v1/integrations/IntegrationsClearHistoryProps.ts b/packages/rest-typings/src/v1/integrations/IntegrationsClearHistoryProps.ts new file mode 100644 index 0000000000000..7c6fd3a91db52 --- /dev/null +++ b/packages/rest-typings/src/v1/integrations/IntegrationsClearHistoryProps.ts @@ -0,0 +1,16 @@ +import { ajv } from '../Ajv'; + +export type IntegrationsClearHistoryProps = { + integrationId: string; +}; + +const IntegrationsClearHistorySchema = { + type: 'object', + properties: { + integrationId: { type: 'string', minLength: 1 }, + }, + required: ['integrationId'], + additionalProperties: false, +}; + +export const isIntegrationsClearHistoryProps = ajv.compile(IntegrationsClearHistorySchema); diff --git a/packages/rest-typings/src/v1/integrations/IntegrationsReplayProps.ts b/packages/rest-typings/src/v1/integrations/IntegrationsReplayProps.ts new file mode 100644 index 0000000000000..a4715e894fc29 --- /dev/null +++ b/packages/rest-typings/src/v1/integrations/IntegrationsReplayProps.ts @@ -0,0 +1,18 @@ +import { ajv } from '../Ajv'; + +export type IntegrationsReplayProps = { + integrationId: string; + historyId: string; +}; + +const IntegrationsReplaySchema = { + type: 'object', + properties: { + integrationId: { type: 'string', minLength: 1 }, + historyId: { type: 'string', minLength: 1 }, + }, + required: ['integrationId', 'historyId'], + additionalProperties: false, +}; + +export const isIntegrationsReplayProps = ajv.compile(IntegrationsReplaySchema); diff --git a/packages/rest-typings/src/v1/integrations/index.ts b/packages/rest-typings/src/v1/integrations/index.ts index 2e63fcc78b1c2..d6201606147cc 100644 --- a/packages/rest-typings/src/v1/integrations/index.ts +++ b/packages/rest-typings/src/v1/integrations/index.ts @@ -1,8 +1,10 @@ export type * from './integrations'; +export * from './IntegrationsClearHistoryProps'; export * from './IntegrationsCreateProps'; export * from './IntegrationsHistoryProps'; export * from './IntegrationsRemoveProps'; +export * from './IntegrationsReplayProps'; export * from './IntegrationsGetProps'; export * from './IntegrationsUpdateProps'; export * from './IntegrationsListProps'; diff --git a/packages/rest-typings/src/v1/integrations/integrations.ts b/packages/rest-typings/src/v1/integrations/integrations.ts index c2833f20bdbe0..24c9355ca38a4 100644 --- a/packages/rest-typings/src/v1/integrations/integrations.ts +++ b/packages/rest-typings/src/v1/integrations/integrations.ts @@ -1,10 +1,12 @@ import type { IIntegration, IIntegrationHistory } from '@rocket.chat/core-typings'; +import type { IntegrationsClearHistoryProps } from './IntegrationsClearHistoryProps'; import type { IntegrationsCreateProps } from './IntegrationsCreateProps'; import type { IntegrationsGetProps } from './IntegrationsGetProps'; import type { IntegrationsHistoryProps } from './IntegrationsHistoryProps'; import type { IntegrationsListProps } from './IntegrationsListProps'; import type { IntegrationsRemoveProps } from './IntegrationsRemoveProps'; +import type { IntegrationsReplayProps } from './IntegrationsReplayProps'; import type { IntegrationsUpdateProps } from './IntegrationsUpdateProps'; import type { PaginatedResult } from '../../helpers/PaginatedResult'; @@ -40,4 +42,12 @@ export type IntegrationsEndpoints = { '/v1/integrations.update': { PUT: (params: IntegrationsUpdateProps) => { integration: IIntegration | null }; }; + + '/v1/integrations.history.clear': { + POST: (params: IntegrationsClearHistoryProps) => void; + }; + + '/v1/integrations.outgoing.replay': { + POST: (params: IntegrationsReplayProps) => void; + }; }; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index eff60fe5c1ee5..c5b3c0075de34 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -128,6 +128,20 @@ const RoomsInfoSchema = { export const isRoomsInfoProps = ajv.compile(RoomsInfoSchema); +type RoomsGetByTypeAndNameProps = { type: IRoom['t']; name: string }; + +const RoomsGetByTypeAndNameSchema = { + type: 'object', + properties: { + type: { type: 'string', enum: ['c', 'p', 'd', 'l', 'v'] }, + name: { type: 'string', minLength: 1 }, + }, + required: ['type', 'name'], + additionalProperties: false, +}; + +export const isRoomsGetByTypeAndNameProps = ajvQuery.compile(RoomsGetByTypeAndNameSchema); + type RoomsCreateDiscussionProps = { prid: IRoom['_id']; pmid?: IMessage['_id']; @@ -883,6 +897,12 @@ export type RoomsEndpoints = { }; }; + '/v1/rooms.getByTypeAndName': { + GET: (params: RoomsGetByTypeAndNameProps) => { + room: Partial; + }; + }; + '/v1/rooms.cleanHistory': { POST: (params: RoomsCleanHistoryProps) => { _id: IRoom['_id']; count: number; success: boolean }; }; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index dda7d95c75330..7e5009fafb74e 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -371,6 +371,10 @@ export type UsersEndpoints = { '/v1/users.deleteOwnAccount': { POST: (params: { password: string; confirmRelinquish?: boolean }) => void; }; + + '/v1/users.verifyEmail': { + POST: (params: { token: string }) => void; + }; }; export * from './users/UserCreateParamsPOST'; From 53ee70cf2af8dce5fdb1712c1be0e12787a72bfa Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 29 May 2026 09:38:19 -0300 Subject: [PATCH 2/5] fix: address TS errors in batch4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users.ts setAvatar: cast service through 'rest' overload literal since the helper accepts arbitrary strings at runtime but the TS overload for Buffer dataURI restricts the type. - UserProvider: restore the local logout Emitter for onLogout(cb) consumers (e2ee cleanup, etc.) — only the sdk.call('logoutCleanUp') side effect is gone, the in-process broadcaster stays. - useOpenRoom: cast Partial response through unknown to IRoom to match downstream consumers that expect the full shape. Co-Authored-By: Claude Opus 4.7 --- apps/meteor/app/api/server/v1/users.ts | 2 +- .../client/providers/UserProvider/UserProvider.tsx | 13 +++++++++---- apps/meteor/client/views/room/hooks/useOpenRoom.ts | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 85dc87123c21a..1b3798fa60c91 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -347,7 +347,7 @@ API.v1 const service = typeof fields.service === 'string' && fields.service.length > 0 ? fields.service : 'rest'; - await setUserAvatar(user, fileBuffer, mimetype, service); + await setUserAvatar(user, fileBuffer, mimetype, service as 'rest'); return API.v1.success(); }, diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index c528dbc2383df..65862b3455560 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -1,4 +1,5 @@ import type { IRoom } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; import type { FindOptions, SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; @@ -18,6 +19,7 @@ import { useUpdateAvatar } from './hooks/useUpdateAvatar'; import { useIdleConnection } from '../../hooks/useIdleConnection'; import type { IDocumentMapStore } from '../../lib/cachedStores/DocumentMapStore'; import { applyQueryOptions } from '../../lib/cachedStores/applyQueryOptions'; +import { getDdpSdk } from '../../lib/sdk/ddpSdk'; import { settings } from '../../lib/settings'; import { userIdStore } from '../../lib/user'; import { Users, Rooms, Subscriptions } from '../../stores'; @@ -27,10 +29,13 @@ type UserProviderProps = { children: ReactNode; }; -// `afterLogoutCleanUpCallback` and `Apps.IPostUserLoggedOut` are now fired -// server-side by the `Accounts.onLogout` hook (and by `/v1/users.logout` -// after token invalidation), so the client does not need to make a round -// trip to invoke them. +// Local logout broadcaster — `onLogout(cb)` consumers (e.g. e2ee cleanup) still +// subscribe to this. The post-logout side effects that used to require a +// `sdk.call('logoutCleanUp')` round-trip (afterLogoutCleanUpCallback + +// Apps.IPostUserLoggedOut) now fire server-side via `Accounts.onLogout` and +// `POST /v1/users.logout`, so this emitter is purely client-side fan-out. +const ee = new Emitter(); +getDdpSdk().account.onLogout(() => ee.emit('logout')); const queryRoom = ( query: Filter>, diff --git a/apps/meteor/client/views/room/hooks/useOpenRoom.ts b/apps/meteor/client/views/room/hooks/useOpenRoom.ts index 6028e91cb1a6b..ef2c541ae5bd2 100644 --- a/apps/meteor/client/views/room/hooks/useOpenRoom.ts +++ b/apps/meteor/client/views/room/hooks/useOpenRoom.ts @@ -76,7 +76,7 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st let roomData: IRoom; try { - roomData = (await getRoomByTypeAndName({ type, name: reference })).room as IRoom; + roomData = (await getRoomByTypeAndName({ type, name: reference })).room as unknown as IRoom; } catch (error) { if (type !== 'd') { throw new RoomNotFoundError(undefined, { type, reference }); From 9bfa11753ecb6f56cc2f0142178dc1e971791b1a Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 29 May 2026 10:26:01 -0300 Subject: [PATCH 3/5] revert: drop getRoomByTypeAndName REST migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback — getRoomByTypeAndName may be replaced by a breaking change (e.g. URLs moving to point directly to rid), so the DDP method stays as-is and the publications/room/index.ts file is restored to its develop baseline. Removed: - GET /v1/rooms.getByTypeAndName endpoint + rest-typings entry - getRoomByTypeAndNameMethod extraction + deprecation log - Client callers (useOpenRoom, EmbeddedPreload) restored to useMethod Co-Authored-By: Claude Opus 4.7 --- .changeset/ddp-migrate-batch4-callers.md | 3 +- .changeset/rest-rooms-get-by-type-and-name.md | 6 -- apps/meteor/app/api/server/v1/rooms.ts | 32 +------ .../client/views/room/hooks/useOpenRoom.ts | 6 +- .../views/root/MainLayout/EmbeddedPreload.tsx | 6 +- apps/meteor/server/publications/room/index.ts | 84 +++++++++---------- packages/rest-typings/src/v1/rooms.ts | 20 ----- 7 files changed, 47 insertions(+), 110 deletions(-) delete mode 100644 .changeset/rest-rooms-get-by-type-and-name.md diff --git a/.changeset/ddp-migrate-batch4-callers.md b/.changeset/ddp-migrate-batch4-callers.md index 9cb97b175bf96..4f9cb92413c69 100644 --- a/.changeset/ddp-migrate-batch4-callers.md +++ b/.changeset/ddp-migrate-batch4-callers.md @@ -2,11 +2,10 @@ '@rocket.chat/meteor': patch --- -Migrate seven client DDP callers to their new REST equivalents (DDP methods stay registered for external SDK/mobile clients with deprecation logs until 9.0.0): +Migrate six client DDP callers to their new REST equivalents (DDP methods stay registered for external SDK/mobile clients with deprecation logs until 9.0.0): - `logoutCleanUp` → side effects now run server-side via `Accounts.onLogout` + `POST /v1/users.logout`; client `sdk.call` dropped. - `setAvatarFromService` → `POST /v1/users.setAvatar` (new `service` multipart field). -- `getRoomByTypeAndName` → `GET /v1/rooms.getByTypeAndName`. - `cloud:connectWorkspace` → `POST /v1/cloud.connectWorkspace`. - `verifyEmail` + `afterVerifyEmail` → single `POST /v1/users.verifyEmail` call. - `authorization:addPermissionToRole` / `removeRoleFromPermission` → `POST /v1/permissions.addRole` / `POST /v1/permissions.removeRole`. diff --git a/.changeset/rest-rooms-get-by-type-and-name.md b/.changeset/rest-rooms-get-by-type-and-name.md deleted file mode 100644 index 5e82674b3485b..0000000000000 --- a/.changeset/rest-rooms-get-by-type-and-name.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -Added `GET /v1/rooms.getByTypeAndName` (replaces the deprecated `getRoomByTypeAndName` DDP method). Query params `{ type, name }`; not auth-gated so anonymous-read flows for public channels keep working (`Accounts_AllowAnonymousRead`). The legacy DDP method remains registered with a deprecation log pointing at the new route. diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 05ed38076c418..d927487d9b868 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -35,7 +35,6 @@ import { isRoomsAutocompleteChannelAndPrivateWithPaginationProps, isRoomsAutocompleteAvailableForTeamsProps, isRoomsSaveRoomSettingsProps, - isRoomsGetByTypeAndNameProps, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, @@ -57,7 +56,7 @@ import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { muteUserInRoom } from '../../../../server/methods/muteUserInRoom'; import { toggleFavoriteMethod } from '../../../../server/methods/toggleFavorite'; import { unmuteUserInRoom } from '../../../../server/methods/unmuteUserInRoom'; -import { getRoomByTypeAndNameMethod, roomsGetMethod } from '../../../../server/publications/room'; +import { roomsGetMethod } from '../../../../server/publications/room'; import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { stripABACManagedFieldsForAdmin } from '../../../authorization/server/lib/isABACManagedRoom'; @@ -556,35 +555,6 @@ API.v1.get( }, ); -API.v1.get( - 'rooms.getByTypeAndName', - { - authRequired: false, - query: isRoomsGetByTypeAndNameProps, - response: { - 200: ajv.compile<{ room: Partial }>({ - type: 'object', - properties: { - room: { type: 'object' }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['room', 'success'], - additionalProperties: false, - }), - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 403: validateForbiddenErrorResponse, - }, - }, - async function action() { - const { type, name } = this.queryParams; - - const room = await getRoomByTypeAndNameMethod(this.userId ?? null, type, name); - - return API.v1.success({ room }); - }, -); - API.v1.post( 'rooms.createDiscussion', { diff --git a/apps/meteor/client/views/room/hooks/useOpenRoom.ts b/apps/meteor/client/views/room/hooks/useOpenRoom.ts index ef2c541ae5bd2..76dd6d9cc2d18 100644 --- a/apps/meteor/client/views/room/hooks/useOpenRoom.ts +++ b/apps/meteor/client/views/room/hooks/useOpenRoom.ts @@ -1,6 +1,6 @@ import { isPublicRoom, type IRoom, type RoomType } from '@rocket.chat/core-typings'; import { getObjectKeys } from '@rocket.chat/tools'; -import { useEndpoint, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useMethod, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect } from 'react'; @@ -19,7 +19,7 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st const user = useUser(); const hasPreviewPermission = usePermission('preview-c-room'); const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', true); - const getRoomByTypeAndName = useEndpoint('GET', '/v1/rooms.getByTypeAndName'); + const getRoomByTypeAndName = useMethod('getRoomByTypeAndName'); const createDirectMessage = useEndpoint('POST', '/v1/im.create'); const directRoute = useRoute('direct'); const openRoom = useOpenRoomMutation(); @@ -76,7 +76,7 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st let roomData: IRoom; try { - roomData = (await getRoomByTypeAndName({ type, name: reference })).room as unknown as IRoom; + roomData = await getRoomByTypeAndName(type, reference); } catch (error) { if (type !== 'd') { throw new RoomNotFoundError(undefined, { type, reference }); diff --git a/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx b/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx index 90f099417f594..35edbf4e78ee1 100644 --- a/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx +++ b/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx @@ -1,4 +1,4 @@ -import { useEndpoint, useRouter, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useMethod, useRouter, useUserId } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement, ReactNode } from 'react'; import { useEffect, useMemo } from 'react'; @@ -34,7 +34,7 @@ const EmbeddedPreload = ({ children }: { children: ReactNode }): ReactElement => return directives.extractOpenRoomParams(router.getRouteParameters()); }, [router]); - const getRoomByTypeAndName = useEndpoint('GET', '/v1/rooms.getByTypeAndName'); + const getRoomByTypeAndName = useMethod('getRoomByTypeAndName'); const getSubscription = useEndpoint('GET', '/v1/subscriptions.getOne'); const shouldFetch = !!roomParams && !!uid; @@ -46,7 +46,7 @@ const EmbeddedPreload = ({ children }: { children: ReactNode }): ReactElement => return null; } - const { room: roomData } = await getRoomByTypeAndName({ type: roomParams.type, name: roomParams.reference }); + const roomData = await getRoomByTypeAndName(roomParams.type, roomParams.reference); if (!roomData?._id) { return null; } diff --git a/apps/meteor/server/publications/room/index.ts b/apps/meteor/server/publications/room/index.ts index d6004c5bfc777..9d143a696aca4 100644 --- a/apps/meteor/server/publications/room/index.ts +++ b/apps/meteor/server/publications/room/index.ts @@ -1,12 +1,11 @@ import type { IOmnichannelRoom, IRoom, RoomType } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; import { canAccessRoomAsync } from '../../../app/authorization/server'; import { hasPermissionAsync } from '../../../app/authorization/server/functions/hasPermission'; -import { methodDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../app/settings/server'; import { roomFields } from '../../../lib/publishFields'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; @@ -46,60 +45,55 @@ export const roomsGetMethod = async (userId?: string | null, updatedAt?: Date): return (await Rooms.findBySubscriptionUserId(userId, options)).toArray(); }; -export const getRoomByTypeAndNameMethod = async (userId: string | null, type: RoomType, name: string): Promise => { - if (!type || !name) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'getRoomByTypeAndName', - }); - } - - const user = userId ? await Users.findOneById(userId) : null; - const isAnonymous = !user?._id; +Meteor.methods({ + async 'rooms/get'(updatedAt) { + return roomsGetMethod(Meteor.userId(), updatedAt); + }, - if (isAnonymous) { - const allowAnon = settings.get('Accounts_AllowAnonymousRead'); - if (!allowAnon || type !== 'c') { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { + async 'getRoomByTypeAndName'(type, name) { + if (!type || !name) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'getRoomByTypeAndName', }); } - } - const roomFind = roomCoordinator.getRoomFind(type); + const user = await Meteor.userAsync(); + const isAnonymous = !user?._id; - const room = roomFind ? await roomFind.call({}, name) : await Rooms.findByTypeAndNameOrId(type, name); + if (isAnonymous) { + const allowAnon = settings.get('Accounts_AllowAnonymousRead'); + if (!allowAnon || type !== 'c') { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'getRoomByTypeAndName', + }); + } + } - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'getRoomByTypeAndName', - }); - } + const roomFind = roomCoordinator.getRoomFind(type); - if ( - user && - !(await canAccessRoomAsync(room, user, { - includeInvitations: true, - })) - ) { - throw new Meteor.Error('error-no-permission', 'No permission', { - method: 'getRoomByTypeAndName', - }); - } + const room = roomFind ? await roomFind.call(this, name) : await Rooms.findByTypeAndNameOrId(type, name); - if (settings.get('Store_Last_Message') && user && !(await hasPermissionAsync(user._id, 'preview-c-room'))) { - delete room.lastMessage; - } + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'getRoomByTypeAndName', + }); + } - return roomMap(room); -}; + if ( + user && + !(await canAccessRoomAsync(room, user, { + includeInvitations: true, + })) + ) { + throw new Meteor.Error('error-no-permission', 'No permission', { + method: 'getRoomByTypeAndName', + }); + } -Meteor.methods({ - async 'rooms/get'(updatedAt) { - return roomsGetMethod(Meteor.userId(), updatedAt); - }, + if (settings.get('Store_Last_Message') && user && !(await hasPermissionAsync(user._id, 'preview-c-room'))) { + delete room.lastMessage; + } - async 'getRoomByTypeAndName'(type, name) { - methodDeprecationLogger.method('getRoomByTypeAndName', '9.0.0', '/v1/rooms.getByTypeAndName'); - return getRoomByTypeAndNameMethod(Meteor.userId(), type, name); + return roomMap(room); }, }); diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index c5b3c0075de34..eff60fe5c1ee5 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -128,20 +128,6 @@ const RoomsInfoSchema = { export const isRoomsInfoProps = ajv.compile(RoomsInfoSchema); -type RoomsGetByTypeAndNameProps = { type: IRoom['t']; name: string }; - -const RoomsGetByTypeAndNameSchema = { - type: 'object', - properties: { - type: { type: 'string', enum: ['c', 'p', 'd', 'l', 'v'] }, - name: { type: 'string', minLength: 1 }, - }, - required: ['type', 'name'], - additionalProperties: false, -}; - -export const isRoomsGetByTypeAndNameProps = ajvQuery.compile(RoomsGetByTypeAndNameSchema); - type RoomsCreateDiscussionProps = { prid: IRoom['_id']; pmid?: IMessage['_id']; @@ -897,12 +883,6 @@ export type RoomsEndpoints = { }; }; - '/v1/rooms.getByTypeAndName': { - GET: (params: RoomsGetByTypeAndNameProps) => { - room: Partial; - }; - }; - '/v1/rooms.cleanHistory': { POST: (params: RoomsCleanHistoryProps) => { _id: IRoom['_id']; count: number; success: boolean }; }; From 7401c7ee87294c243c94eee4a25a7fce39dbb9b1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 29 May 2026 10:27:27 -0300 Subject: [PATCH 4/5] Delete .changeset/ddp-migrate-batch4-callers.md --- .changeset/ddp-migrate-batch4-callers.md | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .changeset/ddp-migrate-batch4-callers.md diff --git a/.changeset/ddp-migrate-batch4-callers.md b/.changeset/ddp-migrate-batch4-callers.md deleted file mode 100644 index 4f9cb92413c69..0000000000000 --- a/.changeset/ddp-migrate-batch4-callers.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Migrate six client DDP callers to their new REST equivalents (DDP methods stay registered for external SDK/mobile clients with deprecation logs until 9.0.0): - -- `logoutCleanUp` → side effects now run server-side via `Accounts.onLogout` + `POST /v1/users.logout`; client `sdk.call` dropped. -- `setAvatarFromService` → `POST /v1/users.setAvatar` (new `service` multipart field). -- `cloud:connectWorkspace` → `POST /v1/cloud.connectWorkspace`. -- `verifyEmail` + `afterVerifyEmail` → single `POST /v1/users.verifyEmail` call. -- `authorization:addPermissionToRole` / `removeRoleFromPermission` → `POST /v1/permissions.addRole` / `POST /v1/permissions.removeRole`. -- `clearIntegrationHistory` / `replayOutgoingIntegration` → `POST /v1/integrations.history.clear` / `POST /v1/integrations.outgoing.replay`. From df1d4e72bc85a61f16eafdc1eaa0af408ddb5a68 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 29 Jun 2026 11:51:55 -0300 Subject: [PATCH 5/5] chore: deprecate setAvatarFromService DDP method --- apps/meteor/server/methods/setAvatarFromService.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/meteor/server/methods/setAvatarFromService.ts b/apps/meteor/server/methods/setAvatarFromService.ts index b59100e4a6270..8a3319d991fa2 100644 --- a/apps/meteor/server/methods/setAvatarFromService.ts +++ b/apps/meteor/server/methods/setAvatarFromService.ts @@ -4,6 +4,7 @@ import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; import { setAvatarFromServiceWithValidation } from '../../app/lib/server/functions/setUserAvatar'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,6 +15,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async setAvatarFromService(dataURI, contentType, service, targetUserId) { + methodDeprecationLogger.method('setAvatarFromService', '9.0.0', '/v1/users.setAvatar'); + check(dataURI, String); check(contentType, Match.Optional(String)); check(service, Match.Optional(String));