From e090c860681db86bd23dbac6911772c3d897084c Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 7 Apr 2020 18:37:47 -0300 Subject: [PATCH 01/10] Regression: Fix users raw model (#17204) --- app/api/server/lib/users.js | 4 ++- app/models/server/models/Users.js | 48 ------------------------------- app/models/server/raw/Users.js | 23 ++++++++++----- 3 files changed, 19 insertions(+), 56 deletions(-) diff --git a/app/api/server/lib/users.js b/app/api/server/lib/users.js index 0742c9feab7d7..82194832ceb8b 100644 --- a/app/api/server/lib/users.js +++ b/app/api/server/lib/users.js @@ -1,3 +1,5 @@ +import s from 'underscore.string'; + import { Users } from '../../../models/server/raw'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -19,7 +21,7 @@ export async function findUsersToAutocomplete({ uid, selector }) { limit: 10, }; - const users = await Users.findActiveByUsernameOrNameRegexWithExceptionsAndConditions(selector.term, exceptions, conditions, options).toArray(); + const users = await Users.findActiveByUsernameOrNameRegexWithExceptionsAndConditions(new RegExp(s.escapeRegExp(selector.term), 'i'), exceptions, conditions, options).toArray(); return { items: users, diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 43c9c03530b27..6930ec7a2b3a1 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -221,20 +221,6 @@ export class Users extends Base { return null; } - setLastRoutingTime(userId) { - const query = { - _id: userId, - }; - - const update = { - $set: { - lastRoutingTime: new Date(), - }, - }; - - return this.update(query, update); - } - setLivechatStatus(userId, status) { const query = { _id: userId, @@ -628,40 +614,6 @@ export class Users extends Base { }, options); } - findActiveByUsernameOrNameRegexWithExceptionsAndConditions(searchTerm, exceptions, conditions, options) { - if (exceptions == null) { exceptions = []; } - if (conditions == null) { conditions = {}; } - if (options == null) { options = {}; } - if (!_.isArray(exceptions)) { - exceptions = [exceptions]; - } - - const termRegex = new RegExp(s.escapeRegExp(searchTerm), 'i'); - const query = { - $or: [{ - username: termRegex, - }, { - name: termRegex, - }], - active: true, - type: { - $in: ['user', 'bot'], - }, - $and: [{ - username: { - $exists: true, - }, - }, { - username: { - $nin: exceptions, - }, - }], - ...conditions, - }; - - return this.find(query, options); - } - findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery = []) { if (exceptions == null) { exceptions = []; } if (options == null) { options = {}; } diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index f42be432eb21b..803e1c82e1282 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -1,9 +1,5 @@ -import s from 'underscore.string'; - import { BaseRaw } from './BaseRaw'; -import { Users } from '..'; - export class UsersRaw extends BaseRaw { findUsersInRoles(roles, scope, options) { roles = [].concat(roles); @@ -54,12 +50,26 @@ export class UsersRaw extends BaseRaw { const [agent] = await this.col.aggregate(aggregate).toArray(); if (agent) { - Users.setLastRoutingTime(agent.agentId); + await this.setLastRoutingTime(agent.agentId); } return agent; } + setLastRoutingTime(userId) { + const query = { + _id: userId, + }; + + const update = { + $set: { + lastRoutingTime: new Date(), + }, + }; + + return this.col.updateOne(query, update); + } + async getAgentAndAmountOngoingChats(userId) { const aggregate = [ { $match: { _id: userId, status: { $exists: true, $ne: 'offline' }, statusLivechat: 'available', roles: 'livechat-agent' } }, @@ -97,7 +107,7 @@ export class UsersRaw extends BaseRaw { ]).toArray(); } - findActiveByUsernameOrNameRegexWithExceptionsAndConditions(searchTerm, exceptions, conditions, options) { + findActiveByUsernameOrNameRegexWithExceptionsAndConditions(termRegex, exceptions, conditions, options) { if (exceptions == null) { exceptions = []; } if (conditions == null) { conditions = {}; } if (options == null) { options = {}; } @@ -105,7 +115,6 @@ export class UsersRaw extends BaseRaw { exceptions = [exceptions]; } - const termRegex = new RegExp(s.escapeRegExp(searchTerm), 'i'); const query = { $or: [{ username: termRegex, From 04ce058035349ccdbf6315e66522e1b6314016f3 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 7 Apr 2020 23:22:30 -0300 Subject: [PATCH 02/10] Add statistics and metrics about push queue (#17208) --- app/metrics/server/lib/metrics.js | 4 ++++ app/statistics/server/lib/statistics.js | 3 +++ server/startup/migrations/v181.js | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/metrics/server/lib/metrics.js b/app/metrics/server/lib/metrics.js index d5f7b39530915..6dad7c38be2df 100644 --- a/app/metrics/server/lib/metrics.js +++ b/app/metrics/server/lib/metrics.js @@ -75,6 +75,8 @@ metrics.oplog = new client.Counter({ labelNames: ['collection', 'op'], }); +metrics.pushQueue = new client.Gauge({ name: 'rocketchat_push_queue', labelNames: ['queue'], help: 'push queue' }); + // User statistics metrics.totalUsers = new client.Gauge({ name: 'rocketchat_users_total', help: 'total of users' }); metrics.activeUsers = new client.Gauge({ name: 'rocketchat_users_active', help: 'total of active users' }); @@ -144,6 +146,8 @@ const setPrometheusData = async () => { const oplogQueue = getOplogInfo().mongo._oplogHandle?._entryQueue?.length || 0; metrics.oplogQueue.set(oplogQueue); + + metrics.pushQueue.set(statistics.pushQueue || 0); }; const app = connect(); diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index c7b79f75948e9..b2907cd0ad817 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -1,6 +1,7 @@ import os from 'os'; import _ from 'underscore'; +import { Push } from 'meteor/rocketchat:push'; import { Meteor } from 'meteor/meteor'; import { InstanceStatus } from 'meteor/konecty:multiple-instances-status'; @@ -165,6 +166,8 @@ export const statistics = { totalWithScriptEnabled: integrations.filter((integration) => integration.scriptEnabled === true).length, }; + statistics.pushQueue = Push.notifications.find().count(); + return statistics; }, save() { diff --git a/server/startup/migrations/v181.js b/server/startup/migrations/v181.js index 4d00b5ab7397b..bdcf8fbfd08cd 100644 --- a/server/startup/migrations/v181.js +++ b/server/startup/migrations/v181.js @@ -13,6 +13,6 @@ Migrations.add({ date.setHours(date.getHours() - 2); // 2 hours ago; // Remove all records older than 2h - await Push.notifications.rawCollection().removeMany({ createdAt: { $lt: date } }); + Push.notifications.rawCollection().removeMany({ createdAt: { $lt: date } }); }, }); From 44c40690b0a84165d2874ed1b7d92204c268a644 Mon Sep 17 00:00:00 2001 From: pierre-lehnen-rc <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 8 Apr 2020 13:51:46 -0300 Subject: [PATCH 03/10] [FIX] SAML login errors not showing on UI (#17219) --- .../server/saml_server.js | 251 +++++++++--------- client/routes.js | 14 +- 2 files changed, 141 insertions(+), 124 deletions(-) diff --git a/app/meteor-accounts-saml/server/saml_server.js b/app/meteor-accounts-saml/server/saml_server.js index b2767fc8e70ba..0b2c69bf16a18 100644 --- a/app/meteor-accounts-saml/server/saml_server.js +++ b/app/meteor-accounts-saml/server/saml_server.js @@ -243,170 +243,177 @@ Accounts.registerLoginHandler(function(loginRequest) { error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'No matching login attempt found'), }; } - const { emailField, usernameField, nameField, userDataFieldMap, regexes } = getUserDataMapping(); const { defaultUserRole = 'user', roleAttributeName, roleAttributeSync } = Accounts.saml.settings; if (loginResult && loginResult.profile && loginResult.profile[emailField]) { - const emailList = Array.isArray(loginResult.profile[emailField]) ? loginResult.profile[emailField] : [loginResult.profile[emailField]]; - const emailRegex = new RegExp(emailList.map((email) => `^${ RegExp.escape(email) }$`).join('|'), 'i'); + try { + const emailList = Array.isArray(loginResult.profile[emailField]) ? loginResult.profile[emailField] : [loginResult.profile[emailField]]; + const emailRegex = new RegExp(emailList.map((email) => `^${ RegExp.escape(email) }$`).join('|'), 'i'); - const eduPersonPrincipalName = loginResult.profile.eppn; - const profileFullName = getProfileValue(loginResult.profile, nameField, regexes.name); - const fullName = profileFullName || loginResult.profile.displayName || loginResult.profile.username; + const eduPersonPrincipalName = loginResult.profile.eppn; + const profileFullName = getProfileValue(loginResult.profile, nameField, regexes.name); + const fullName = profileFullName || loginResult.profile.displayName || loginResult.profile.username; - let eppnMatch = false; - let user = null; + let eppnMatch = false; + let user = null; - // Check eppn - if (eduPersonPrincipalName) { - user = Meteor.users.findOne({ - eppn: eduPersonPrincipalName, - }); + // Check eppn + if (eduPersonPrincipalName) { + user = Meteor.users.findOne({ + eppn: eduPersonPrincipalName, + }); - if (user) { - eppnMatch = true; + if (user) { + eppnMatch = true; + } } - } - let username; - if (loginResult.profile[usernameField]) { - const profileUsername = getProfileValue(loginResult.profile, usernameField, regexes.username); - if (profileUsername) { - username = Accounts.normalizeUsername(profileUsername); + let username; + if (loginResult.profile[usernameField]) { + const profileUsername = getProfileValue(loginResult.profile, usernameField, regexes.username); + if (profileUsername) { + username = Accounts.normalizeUsername(profileUsername); + } } - } - // If eppn is not exist - if (!user) { - if (Accounts.saml.settings.immutableProperty === 'Username') { - if (username) { + // If eppn is not exist + if (!user) { + if (Accounts.saml.settings.immutableProperty === 'Username') { + if (username) { + user = Meteor.users.findOne({ + username, + }); + } + } else { user = Meteor.users.findOne({ - username, + 'emails.address': emailRegex, }); } + } + + const emails = emailList.map((email) => ({ + address: email, + verified: settings.get('Accounts_Verify_Email_For_External_Accounts'), + })); + + let globalRoles; + if (roleAttributeName && loginResult.profile[roleAttributeName]) { + globalRoles = [].concat(loginResult.profile[roleAttributeName]); } else { - user = Meteor.users.findOne({ - 'emails.address': emailRegex, - }); + globalRoles = [].concat(defaultUserRole.split(',')); } - } - const emails = emailList.map((email) => ({ - address: email, - verified: settings.get('Accounts_Verify_Email_For_External_Accounts'), - })); + if (!user) { + const newUser = { + name: fullName, + active: true, + eppn: eduPersonPrincipalName, + globalRoles, + emails, + services: {}, + }; - let globalRoles; - if (roleAttributeName && loginResult.profile[roleAttributeName]) { - globalRoles = [].concat(loginResult.profile[roleAttributeName]); - } else { - globalRoles = [].concat(defaultUserRole.split(',')); - } + if (Accounts.saml.settings.generateUsername === true) { + username = generateUsernameSuggestion(newUser); + } - if (!user) { - const newUser = { - name: fullName, - active: true, - eppn: eduPersonPrincipalName, - globalRoles, - emails, - services: {}, - }; + if (username) { + newUser.username = username; + newUser.name = newUser.name || guessNameFromUsername(username); + } - if (Accounts.saml.settings.generateUsername === true) { - username = generateUsernameSuggestion(newUser); - } + const languages = TAPi18n.getLanguages(); + if (languages[loginResult.profile.language]) { + newUser.language = loginResult.profile.language; + } - if (username) { - newUser.username = username; - newUser.name = newUser.name || guessNameFromUsername(username); - } + const userId = Accounts.insertUserDoc({}, newUser); + user = Meteor.users.findOne(userId); - const languages = TAPi18n.getLanguages(); - if (languages[loginResult.profile.language]) { - newUser.language = loginResult.profile.language; + if (loginResult.profile.channels) { + const channels = loginResult.profile.channels.split(','); + Accounts.saml.subscribeToSAMLChannels(channels, user); + } } - const userId = Accounts.insertUserDoc({}, newUser); - user = Meteor.users.findOne(userId); - - if (loginResult.profile.channels) { - const channels = loginResult.profile.channels.split(','); - Accounts.saml.subscribeToSAMLChannels(channels, user); + // If eppn is not exist then update + if (eppnMatch === false) { + Meteor.users.update({ + _id: user._id, + }, { + $set: { + eppn: eduPersonPrincipalName, + }, + }); } - } - // If eppn is not exist then update - if (eppnMatch === false) { - Meteor.users.update({ - _id: user._id, - }, { - $set: { - eppn: eduPersonPrincipalName, + // creating the token and adding to the user + const stampedToken = Accounts._generateStampedLoginToken(); + Meteor.users.update(user, { + $push: { + 'services.resume.loginTokens': stampedToken, }, }); - } - // creating the token and adding to the user - const stampedToken = Accounts._generateStampedLoginToken(); - Meteor.users.update(user, { - $push: { - 'services.resume.loginTokens': stampedToken, - }, - }); + const samlLogin = { + provider: Accounts.saml.RelayState, + idp: loginResult.profile.issuer, + idpSession: loginResult.profile.sessionIndex, + nameID: loginResult.profile.nameID, + }; - const samlLogin = { - provider: Accounts.saml.RelayState, - idp: loginResult.profile.issuer, - idpSession: loginResult.profile.sessionIndex, - nameID: loginResult.profile.nameID, - }; + const updateData = { + // TBD this should be pushed, otherwise we're only able to SSO into a single IDP at a time + 'services.saml': samlLogin, + }; - const updateData = { - // TBD this should be pushed, otherwise we're only able to SSO into a single IDP at a time - 'services.saml': samlLogin, - }; + for (const field in userDataFieldMap) { + if (!userDataFieldMap.hasOwnProperty(field)) { + continue; + } - for (const field in userDataFieldMap) { - if (!userDataFieldMap.hasOwnProperty(field)) { - continue; + if (loginResult.profile[field]) { + const rcField = userDataFieldMap[field]; + const value = getProfileValue(loginResult.profile, field, regexes[rcField]); + updateData[`customFields.${ rcField }`] = value; + } } - if (loginResult.profile[field]) { - const rcField = userDataFieldMap[field]; - const value = getProfileValue(loginResult.profile, field, regexes[rcField]); - updateData[`customFields.${ rcField }`] = value; + if (Accounts.saml.settings.immutableProperty !== 'EMail') { + updateData.emails = emails; } - } - - if (Accounts.saml.settings.immutableProperty !== 'EMail') { - updateData.emails = emails; - } - if (roleAttributeSync) { - updateData.roles = globalRoles; - } + if (roleAttributeSync) { + updateData.roles = globalRoles; + } - Meteor.users.update({ - _id: user._id, - }, { - $set: updateData, - }); + Meteor.users.update({ + _id: user._id, + }, { + $set: updateData, + }); - if (username) { - _setUsername(user._id, username); - } + if (username) { + _setUsername(user._id, username); + } - overwriteData(user, fullName, eppnMatch, emailList); + overwriteData(user, fullName, eppnMatch, emailList); - // sending token along with the userId - const result = { - userId: user._id, - token: stampedToken.token, - }; + // sending token along with the userId + const result = { + userId: user._id, + token: stampedToken.token, + }; - return result; + return result; + } catch (error) { + console.error(error); + return { + type: 'saml', + error, + }; + } } throw new Error('SAML Profile did not contain an email address'); }); diff --git a/client/routes.js b/client/routes.js index 08cd28a47ecf6..d616b5deb29ff 100644 --- a/client/routes.js +++ b/client/routes.js @@ -7,10 +7,11 @@ import { HTML } from 'meteor/htmljs'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { BlazeLayout } from 'meteor/kadira:blaze-layout'; import { Session } from 'meteor/session'; +import toastr from 'toastr'; import { KonchatNotification } from '../app/ui'; import { ChatSubscription } from '../app/models'; -import { roomTypes } from '../app/utils'; +import { roomTypes, handleError } from '../app/utils'; import { call } from '../app/ui-utils'; import { createTemplateForComponent } from './createTemplateForComponent'; @@ -75,7 +76,16 @@ FlowRouter.route('/home', { saml: true, credentialToken: queryParams.saml_idp_credentialToken, }], - userCallback() { BlazeLayout.render('main', { center: 'home' }); }, + userCallback(error) { + if (error) { + if (error.reason) { + toastr.error(error.reason); + } else { + handleError(error); + } + } + BlazeLayout.render('main', { center: 'home' }); + }, }); } else { BlazeLayout.render('main', { center: 'home' }); From b7669f8651026527e27165c3492328cb731cb17e Mon Sep 17 00:00:00 2001 From: pierre-lehnen-rc <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 8 Apr 2020 20:50:36 -0300 Subject: [PATCH 04/10] [FIX] Wrong SAML Response Signature Validation (#16922) --- .../server/saml_server.js | 28 +-- app/meteor-accounts-saml/server/saml_utils.js | 184 ++++++++++++++---- 2 files changed, 160 insertions(+), 52 deletions(-) diff --git a/app/meteor-accounts-saml/server/saml_server.js b/app/meteor-accounts-saml/server/saml_server.js index 0b2c69bf16a18..15768ca68c34c 100644 --- a/app/meteor-accounts-saml/server/saml_server.js +++ b/app/meteor-accounts-saml/server/saml_server.js @@ -229,6 +229,22 @@ const guessNameFromUsername = (username) => .replace(/^(.)/, (u) => u.toLowerCase()) .replace(/^\w/, (u) => u.toUpperCase()); +const findUser = (username, emailRegex) => { + if (Accounts.saml.settings.immutableProperty === 'Username') { + if (username) { + return Meteor.users.findOne({ + username, + }); + } + + return null; + } + + return Meteor.users.findOne({ + 'emails.address': emailRegex, + }); +}; + Accounts.registerLoginHandler(function(loginRequest) { if (!loginRequest.saml || !loginRequest.credentialToken) { return undefined; @@ -279,17 +295,7 @@ Accounts.registerLoginHandler(function(loginRequest) { // If eppn is not exist if (!user) { - if (Accounts.saml.settings.immutableProperty === 'Username') { - if (username) { - user = Meteor.users.findOne({ - username, - }); - } - } else { - user = Meteor.users.findOne({ - 'emails.address': emailRegex, - }); - } + user = findUser(username, emailRegex); } const emails = emailList.map((email) => ({ diff --git a/app/meteor-accounts-saml/server/saml_utils.js b/app/meteor-accounts-saml/server/saml_utils.js index ac3a3d1ea08bb..5c68621d281bd 100644 --- a/app/meteor-accounts-saml/server/saml_utils.js +++ b/app/meteor-accounts-saml/server/saml_utils.js @@ -311,12 +311,8 @@ SAML.prototype.validateStatus = function(doc) { }; }; -SAML.prototype.validateSignature = function(xml, cert) { +SAML.prototype.validateSignature = function(xml, cert, signature) { const self = this; - - const doc = new xmldom.DOMParser().parseFromString(xml); - const signature = xmlCrypto.xpath(doc, '//*[local-name(.)=\'Signature\' and namespace-uri(.)=\'http://www.w3.org/2000/09/xmldsig#\']')[0]; - const sig = new xmlCrypto.SignedXml(); sig.keyInfoProvider = { @@ -333,6 +329,35 @@ SAML.prototype.validateSignature = function(xml, cert) { return sig.checkSignature(xml); }; +SAML.prototype.validateSignatureChildren = function(xml, cert, parent) { + const xpathSigQuery = ".//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"; + const signatures = xmlCrypto.xpath(parent, xpathSigQuery); + let signature = null; + + for (const sign of signatures) { + if (sign.parentNode !== parent) { + continue; + } + + // Too many signatures + if (signature) { + return false; + } + + signature = sign; + } + + return this.validateSignature(xml, cert, signature); +}; + +SAML.prototype.validateResponseSignature = function(xml, cert, response) { + return this.validateSignatureChildren(xml, cert, response); +}; + +SAML.prototype.validateAssertionSignature = function(xml, cert, assertion) { + return this.validateSignatureChildren(xml, cert, assertion); +}; + SAML.prototype.validateLogoutRequest = function(samlRequest, callback) { const compressedSAMLRequest = new Buffer(samlRequest, 'base64'); zlib.inflateRaw(compressedSAMLRequest, function(err, decoded) { @@ -464,6 +489,23 @@ SAML.prototype.mapAttributes = function(attributeStatement, profile) { } }; +SAML.prototype.validateAssertionConditions = function(assertion) { + const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0]; + if (conditions && !this.validateNotBeforeNotOnOrAfterAssertions(conditions)) { + throw new Error('NotBefore / NotOnOrAfter assertion failed'); + } +}; + +SAML.prototype.validateSubjectConditions = function(subject) { + const subjectConfirmation = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmation')[0]; + if (subjectConfirmation) { + const subjectConfirmationData = subjectConfirmation.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmationData')[0]; + if (subjectConfirmationData && !this.validateNotBeforeNotOnOrAfterAssertions(subjectConfirmationData)) { + throw new Error('NotBefore / NotOnOrAfter assertion failed'); + } + } +}; + SAML.prototype.validateNotBeforeNotOnOrAfterAssertions = function(element) { const sysnow = new Date(); const allowedclockdrift = this.options.allowedClockDrift; @@ -491,6 +533,76 @@ SAML.prototype.validateNotBeforeNotOnOrAfterAssertions = function(element) { return true; }; +SAML.prototype.getAssertion = function(response) { + const allAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion'); + const allEncrypedAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion'); + + if (allAssertions.length + allEncrypedAssertions.length > 1) { + throw new Error('Too many SAML assertions'); + } + + let assertion = allAssertions[0]; + const encAssertion = allEncrypedAssertions[0]; + + + if (typeof encAssertion !== 'undefined') { + const options = { key: this.options.privateKey }; + xmlenc.decrypt(encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { + assertion = new xmldom.DOMParser().parseFromString(result, 'text/xml'); + }); + } + + if (!assertion) { + throw new Error('Missing SAML assertion'); + } + + return assertion; +}; + +SAML.prototype.verifySignatures = function(response, assertion, xml) { + if (!this.options.cert) { + return; + } + + debugLog('Verify Document Signature'); + if (!this.validateResponseSignature(xml, this.options.cert, response)) { + debugLog('Document Signature WRONG'); + throw new Error('Invalid Signature'); + } + debugLog('Document Signature OK'); + + debugLog('Verify Assertion Signature'); + if (!this.validateAssertionSignature(xml, this.options.cert, assertion)) { + debugLog('Assertion Signature WRONG'); + throw new Error('Invalid Assertion signature'); + } + debugLog('Assertion Signature OK'); +}; + +SAML.prototype.getSubject = function(assertion) { + let subject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0]; + const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0]; + + if (typeof encSubject !== 'undefined') { + const options = { key: this.options.privateKey }; + xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { + subject = new xmldom.DOMParser().parseFromString(result, 'text/xml'); + }); + } + + return subject; +}; + +SAML.prototype.getIssuer = function(assertion) { + const issuers = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer'); + + if (issuers.length > 1) { + throw new Error('Too many Issuers'); + } + + return issuers[0]; +}; + SAML.prototype.validateResponse = function(samlResponse, relayState, callback) { const self = this; const xml = new Buffer(samlResponse, 'base64').toString('utf8'); @@ -510,15 +622,12 @@ SAML.prototype.validateResponse = function(samlResponse, relayState, callback) { } debugLog('Status ok'); - // Verify signature - debugLog('Verify signature'); - if (self.options.cert && !self.validateSignature(xml, self.options.cert)) { - debugLog('Signature WRONG'); - return callback(new Error('Invalid signature'), null, false); + const allResponses = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response'); + if (allResponses.length !== 1) { + return callback(new Error('Too many SAML responses'), null, false); } - debugLog('Signature OK'); - const response = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response')[0]; + const response = allResponses[0]; if (!response) { const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse'); @@ -529,19 +638,15 @@ SAML.prototype.validateResponse = function(samlResponse, relayState, callback) { } debugLog('Got response'); - let assertion = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion')[0]; - const encAssertion = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion')[0]; + let assertion; + let issuer; - const options = { key: this.options.privateKey }; + try { + assertion = this.getAssertion(response, callback); - if (typeof encAssertion !== 'undefined') { - xmlenc.decrypt(encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { - assertion = new xmldom.DOMParser().parseFromString(result, 'text/xml'); - }); - } - - if (!assertion) { - return callback(new Error('Missing SAML assertion'), null, false); + this.verifySignatures(response, assertion, xml); + } catch (e) { + return callback(e, null, false); } const profile = {}; @@ -550,19 +655,17 @@ SAML.prototype.validateResponse = function(samlResponse, relayState, callback) { profile.inResponseToId = response.getAttribute('InResponseTo'); } - const issuer = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer')[0]; + try { + issuer = this.getIssuer(assertion); + } catch (e) { + return callback(e, null, false); + } + if (issuer) { profile.issuer = issuer.textContent; } - let subject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0]; - const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0]; - - if (typeof encSubject !== 'undefined') { - xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, function(err, result) { - subject = new xmldom.DOMParser().parseFromString(result, 'text/xml'); - }); - } + const subject = this.getSubject(assertion); if (subject) { const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0]; @@ -574,18 +677,17 @@ SAML.prototype.validateResponse = function(samlResponse, relayState, callback) { } } - const subjectConfirmation = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmation')[0]; - if (subjectConfirmation) { - const subjectConfirmationData = subjectConfirmation.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmationData')[0]; - if (subjectConfirmationData && !this.validateNotBeforeNotOnOrAfterAssertions(subjectConfirmationData)) { - return callback(new Error('NotBefore / NotOnOrAfter assertion failed'), null, false); - } + try { + this.validateSubjectConditions(subject); + } catch (e) { + return callback(e, null, false); } } - const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0]; - if (conditions && !this.validateNotBeforeNotOnOrAfterAssertions(conditions)) { - return callback(new Error('NotBefore / NotOnOrAfter assertion failed'), null, false); + try { + this.validateAssertionConditions(assertion); + } catch (e) { + return callback(e, null, false); } const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0]; From e5aee3dcc8106f4c27551662520604ca26e72cd3 Mon Sep 17 00:00:00 2001 From: Renato Becker Date: Wed, 8 Apr 2020 20:59:40 -0300 Subject: [PATCH 05/10] Removed the invalid and unnecessary parameter clientAction. (#17224) --- server/publications/room/emitter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/publications/room/emitter.js b/server/publications/room/emitter.js index 82a489e120967..ad5dabb524fe6 100644 --- a/server/publications/room/emitter.js +++ b/server/publications/room/emitter.js @@ -26,7 +26,7 @@ Rooms.on('change', ({ clientAction, id, data }) => { return; } if (clientAction === 'removed') { - getSubscriptions(clientAction, id).forEach(({ u }) => { + getSubscriptions(id).forEach(({ u }) => { Notifications.notifyUserInThisInstance( u._id, 'rooms-changed', From b5b74e8aa889888d6ea93b7120c3143da517ed67 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 8 Apr 2020 23:18:04 -0300 Subject: [PATCH 06/10] Collect metrics about meteor facts (#17216) --- .meteor/packages | 1 + .meteor/versions | 1 + app/metrics/server/lib/metrics.js | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/.meteor/packages b/.meteor/packages index 4ffeb0af3f1af..e1895f9bc43e3 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -97,3 +97,4 @@ webapp-hashing@1.0.9 rocketchat:oauth2-server rocketchat:i18n dandv:caret-position +facts-base diff --git a/.meteor/versions b/.meteor/versions index dd8bf4367eed0..ee1d117926a65 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -43,6 +43,7 @@ ejson@1.1.1 email@1.2.3 es5-shim@4.8.0 facebook-oauth@1.6.0 +facts-base@1.0.1 fastclick@1.0.13 fetch@0.1.1 geojson-utils@1.0.10 diff --git a/app/metrics/server/lib/metrics.js b/app/metrics/server/lib/metrics.js index 6dad7c38be2df..a3d5de8c8dbe3 100644 --- a/app/metrics/server/lib/metrics.js +++ b/app/metrics/server/lib/metrics.js @@ -5,6 +5,7 @@ import connect from 'connect'; import _ from 'underscore'; import gcStats from 'prometheus-gc-stats'; import { Meteor } from 'meteor/meteor'; +import { Facts } from 'meteor/facts-base'; import { Info, getOplogInfo } from '../../../utils/server'; import { Migrations } from '../../../migrations'; @@ -99,6 +100,13 @@ metrics.totalPrivateGroupMessages = new client.Gauge({ name: 'rocketchat_private metrics.totalDirectMessages = new client.Gauge({ name: 'rocketchat_direct_messages_total', help: 'total of messages in direct rooms' }); metrics.totalLivechatMessages = new client.Gauge({ name: 'rocketchat_livechat_messages_total', help: 'total of messages in livechat rooms' }); +// Meteor Facts +metrics.meteorFacts = new client.Gauge({ name: 'rocketchat_meteor_facts', labelNames: ['pkg', 'fact'], help: 'internal meteor facts' }); + +Facts.incrementServerFact = function(pkg, fact, increment) { + metrics.meteorFacts.inc({ pkg, fact }, increment); +}; + const setPrometheusData = async () => { metrics.info.set({ version: Info.version, From 7692896441d1b5bb884b3f4298e7bc6ea9c1b7c8 Mon Sep 17 00:00:00 2001 From: pierre-lehnen-rc <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 8 Apr 2020 23:18:31 -0300 Subject: [PATCH 07/10] [FIX] Random errors on SAML logout (#17227) --- app/meteor-accounts-saml/client/saml_client.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/meteor-accounts-saml/client/saml_client.js b/app/meteor-accounts-saml/client/saml_client.js index 0e4e06d93bd79..0c30b46093c6f 100644 --- a/app/meteor-accounts-saml/client/saml_client.js +++ b/app/meteor-accounts-saml/client/saml_client.js @@ -58,8 +58,12 @@ Meteor.logoutWithSaml = function(options/* , callback*/) { MeteorLogout.apply(Meteor); return; } + + // Remove the userId from the client to prevent calls to the server while the logout is processed. + // If the logout fails, the userId will be reloaded on the resume call + Meteor._localStorage.removeItem(Accounts.USER_ID_KEY); + // A nasty bounce: 'result' has the SAML LogoutRequest but we need a proper 302 to redirected from the server. - // window.location.replace(Meteor.absoluteUrl('_saml/sloRedirect/' + options.provider + '/?redirect='+result)); window.location.replace(Meteor.absoluteUrl(`_saml/sloRedirect/${ options.provider }/?redirect=${ encodeURIComponent(result) }`)); }); }; From 76a1160713da7e9127b7eb01a97b25e585399eac Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Thu, 9 Apr 2020 18:10:17 -0300 Subject: [PATCH 08/10] [CHORE] Use REST API for sending audio messages (#17237) --- .../messageBox/messageBoxAudioMessage.js | 76 +-------- app/ui/client/lib/fileUpload.js | 149 +++++++++--------- 2 files changed, 78 insertions(+), 147 deletions(-) diff --git a/app/ui-message/client/messageBox/messageBoxAudioMessage.js b/app/ui-message/client/messageBox/messageBoxAudioMessage.js index 1bcf4f8c7df8d..a96832d1b01c6 100644 --- a/app/ui-message/client/messageBox/messageBoxAudioMessage.js +++ b/app/ui-message/client/messageBox/messageBoxAudioMessage.js @@ -1,12 +1,9 @@ import { ReactiveVar } from 'meteor/reactive-var'; -import { Session } from 'meteor/session'; -import { Tracker } from 'meteor/tracker'; import { Template } from 'meteor/templating'; -import { fileUploadHandler } from '../../../file-upload'; +import { uploadFileWithMessage } from '../../../ui/client/lib/fileUpload'; import { settings } from '../../../settings'; import { AudioRecorder } from '../../../ui'; -import { call } from '../../../ui-utils'; import { t } from '../../../utils'; import './messageBoxAudioMessage.html'; @@ -15,74 +12,6 @@ const startRecording = () => new Promise((resolve, reject) => const stopRecording = () => new Promise((resolve) => AudioRecorder.stop(resolve)); -const registerUploadProgress = (upload) => { - const uploads = Session.get('uploading') || []; - Session.set('uploading', [...uploads, { - id: upload.id, - name: upload.getFileName(), - percentage: 0, - }]); -}; - -const updateUploadProgress = (upload, { progress, error: { message: error } = {} }) => { - const uploads = Session.get('uploading') || []; - const item = uploads.find(({ id }) => id === upload.id) || { - id: upload.id, - name: upload.getFileName(), - }; - item.percentage = Math.round(progress * 100) || 0; - item.error = error; - Session.set('uploading', uploads); -}; - -const unregisterUploadProgress = (upload) => setTimeout(() => { - const uploads = Session.get('uploading') || []; - Session.set('uploading', uploads.filter(({ id }) => id !== upload.id)); -}, 2000); - -const uploadRecord = async ({ rid, tmid, blob }) => { - const upload = fileUploadHandler('Uploads', { - name: `${ t('Audio record') }.mp3`, - size: blob.size, - type: 'audio/mpeg', - rid, - description: '', - }, blob); - - upload.onProgress = (progress) => { - updateUploadProgress(upload, { progress }); - }; - - registerUploadProgress(upload); - - try { - const [file, storage] = await new Promise((resolve, reject) => { - upload.start((error, ...args) => (error ? reject(error) : resolve(args))); - }); - - await call('sendFileMessage', rid, storage, file, { tmid }); - - unregisterUploadProgress(upload); - } catch (error) { - updateUploadProgress(upload, { error, progress: 0 }); - unregisterUploadProgress(upload); - } - - Tracker.autorun((c) => { - const cancel = Session.get(`uploading-cancel-${ upload.id }`); - - if (!cancel) { - return; - } - - upload.stop(); - c.stop(); - - updateUploadProgress(upload, { progress: 0 }); - unregisterUploadProgress(upload); - }); -}; - const recordingInterval = new ReactiveVar(null); const recordingRoomId = new ReactiveVar(null); @@ -206,6 +135,7 @@ Template.messageBoxAudioMessage.events({ instance.state.set(null); const { rid, tmid } = this; - await uploadRecord({ rid, tmid, blob }); + + await uploadFileWithMessage(rid, tmid, { file: { file: blob }, fileName: `${ t('Audio record') }.mp3` }); }, }); diff --git a/app/ui/client/lib/fileUpload.js b/app/ui/client/lib/fileUpload.js index dae0bd618a10c..41107998582d9 100644 --- a/app/ui/client/lib/fileUpload.js +++ b/app/ui/client/lib/fileUpload.js @@ -16,6 +16,75 @@ const readAsDataURL = (file, callback) => { return reader.readAsDataURL(file); }; +export const uploadFileWithMessage = async (rid, tmid, { description, fileName, msg, file }) => { + const data = new FormData(); + description && data.append('description', description); + msg && data.append('msg', msg); + tmid && data.append('tmid', tmid); + data.append('file', file.file, fileName); + + const uploads = Session.get('uploading') || []; + + const upload = { + id: Random.id(), + name: fileName, + percentage: 0, + }; + + uploads.push(upload); + Session.set('uploading', uploads); + + const { xhr, promise } = APIClient.upload(`v1/rooms.upload/${ rid }`, {}, data, { + progress(progress) { + const uploads = Session.get('uploading') || []; + + if (progress === 100) { + return; + } + uploads.filter((u) => u.id === upload.id).forEach((u) => { + u.percentage = Math.round(progress) || 0; + }); + Session.set('uploading', uploads); + }, + error(error) { + const uploads = Session.get('uploading') || []; + uploads.filter((u) => u.id === upload.id).forEach((u) => { + u.error = error.message; + u.percentage = 0; + }); + Session.set('uploading', uploads); + }, + }); + + Tracker.autorun((computation) => { + const isCanceling = Session.get(`uploading-cancel-${ upload.id }`); + if (!isCanceling) { + return; + } + computation.stop(); + Session.delete(`uploading-cancel-${ upload.id }`); + + xhr.abort(); + + const uploads = Session.get('uploading') || {}; + Session.set('uploading', uploads.filter((u) => u.id !== upload.id)); + }); + + try { + await promise; + const uploads = Session.get('uploading') || []; + return Session.set('uploading', uploads.filter((u) => u.id !== upload.id)); + } catch (error) { + const uploads = Session.get('uploading') || []; + uploads.filter((u) => u.id === upload.id).forEach((u) => { + u.error = (error.xhr && error.xhr.responseJSON && error.xhr.responseJSON.error) || error.message; + u.percentage = 0; + }); + Session.set('uploading', uploads); + } +}; + + const showUploadPreview = (file, callback) => { // If greater then 10MB don't try and show a preview if (file.file.size > (10 * 1000000)) { @@ -197,84 +266,16 @@ export const fileUpload = async (files, input, { rid, tmid }) => { return; } - const record = { - name: document.getElementById('file-name').value || file.name || file.file.name, - size: file.file.size, - type: file.file.type, - rid, - description: document.getElementById('file-description').value, - }; - const fileName = document.getElementById('file-name').value || file.name || file.file.name; - const data = new FormData(); - record.description && data.append('description', record.description); - msg && data.append('msg', msg); - tmid && data.append('tmid', tmid); - data.append('file', file.file, fileName); - - - const uploads = Session.get('uploading') || []; - - const upload = { - id: Random.id(), - name: fileName, - percentage: 0, - }; - - uploads.push(upload); - Session.set('uploading', uploads); - - uploadNextFile(); - - const { xhr, promise } = APIClient.upload(`v1/rooms.upload/${ rid }`, {}, data, { - progress(progress) { - const uploads = Session.get('uploading') || []; - - if (progress === 100) { - return; - } - uploads.filter((u) => u.id === upload.id).forEach((u) => { - u.percentage = Math.round(progress) || 0; - }); - Session.set('uploading', uploads); - }, - error(error) { - const uploads = Session.get('uploading') || []; - uploads.filter((u) => u.id === upload.id).forEach((u) => { - u.error = error.message; - u.percentage = 0; - }); - Session.set('uploading', uploads); - }, - }); - - Tracker.autorun((computation) => { - const isCanceling = Session.get(`uploading-cancel-${ upload.id }`); - if (!isCanceling) { - return; - } - computation.stop(); - Session.delete(`uploading-cancel-${ upload.id }`); - - xhr.abort(); - - const uploads = Session.get('uploading') || {}; - Session.set('uploading', uploads.filter((u) => u.id !== upload.id)); + uploadFileWithMessage(rid, tmid, { + description: document.getElementById('file-description').value || undefined, + fileName, + msg: msg || undefined, + file, }); - try { - await promise; - const uploads = Session.get('uploading') || []; - return Session.set('uploading', uploads.filter((u) => u.id !== upload.id)); - } catch (error) { - const uploads = Session.get('uploading') || []; - uploads.filter((u) => u.id === upload.id).forEach((u) => { - u.error = (error.xhr && error.xhr.responseJSON && error.xhr.responseJSON.error) || error.message; - u.percentage = 0; - }); - Session.set('uploading', uploads); - } + uploadNextFile(); })); }; From b61ba90c340142a54e8358f64934a3d8c7750bed Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 9 Apr 2020 19:16:57 -0300 Subject: [PATCH 09/10] Fix self DM (#17239) --- app/lib/server/functions/createDirectRoom.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/server/functions/createDirectRoom.js b/app/lib/server/functions/createDirectRoom.js index c8b5cd5cf081d..93aaa63a4602f 100644 --- a/app/lib/server/functions/createDirectRoom.js +++ b/app/lib/server/functions/createDirectRoom.js @@ -34,14 +34,14 @@ export const createDirectRoom = function(members, roomExtraData = {}, options = const uids = members.map(({ _id }) => _id).sort(); // Deprecated: using users' _id to compose the room _id is deprecated - const room = uids.length <= 2 + const room = uids.length === 2 ? Rooms.findOneById(uids.join(''), { fields: { _id: 1 } }) : Rooms.findOneDirectRoomContainingAllUserIDs(uids, { fields: { _id: 1 } }); const isNewRoom = !room; const rid = room?._id || Rooms.insert({ - ...uids.length <= 2 && { _id: uids.join('') }, // Deprecated: using users' _id to compose the room _id is deprecated + ...uids.length === 2 && { _id: uids.join('') }, // Deprecated: using users' _id to compose the room _id is deprecated t: 'd', usernames, usersCount: members.length, From 7a46c7d122bfc96977ac21b8ccddebb9d7bf630c Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Fri, 10 Apr 2020 15:52:10 -0300 Subject: [PATCH 10/10] [IMPROVE] Increase decoupling between React components and Blaze templates (#16642) --- .../client/views/Multiselect.html | 3 - .../client/views/Multiselect.js | 35 +- app/ui-message/client/blocks/Blocks.html | 3 - app/ui-message/client/blocks/Blocks.js | 48 -- .../client/blocks/ButtonElement.html | 3 - app/ui-message/client/blocks/MessageBlock.js | 196 ++------ app/ui-message/client/blocks/ModalBlock.html | 3 - app/ui-message/client/blocks/ModalBlock.js | 441 +++++++++++++----- app/ui-message/client/blocks/TextBlock.html | 12 - app/ui-message/client/blocks/TextBlock.js | 5 - app/ui-message/client/blocks/index.js | 9 +- app/ui-message/client/blocks/styles.css | 18 - app/ui-sidenav/client/SortList.js | 2 + app/ui-sidenav/client/sidebarHeader.js | 6 +- app/ui-utils/client/lib/callMethod.js | 2 +- app/ui/client/index.js | 5 +- app/ui/client/views/app/RoomForeword.html | 3 - app/ui/client/views/app/RoomForeword.js | 46 +- .../views/app/components/Directory/index.js | 2 + .../components/admin/info/InformationRoute.js | 2 + client/components/admin/settings/GroupPage.js | 10 +- .../admin/settings/NotAuthorizedPage.js | 4 +- client/components/admin/settings/Section.js | 15 +- .../admin/settings/SettingsRoute.js | 9 +- .../inputs/MultiSelectSettingInput.js | 2 + .../settings/inputs/StringSettingInput.js | 8 +- .../components/pageNotFound/PageNotFound.js | 2 + .../setupWizard/SetupWizardRoute.js | 2 + client/createTemplateForComponent.js | 66 --- client/providers/MeteorProvider.js | 2 + client/reactAdapters.js | 183 ++++++++ client/routes.js | 89 ++-- .../components/EngagementDashboardRoute.js | 3 +- ee/app/engagement-dashboard/client/routes.js | 15 +- 34 files changed, 651 insertions(+), 603 deletions(-) delete mode 100644 app/channel-settings/client/views/Multiselect.html delete mode 100644 app/ui-message/client/blocks/Blocks.html delete mode 100644 app/ui-message/client/blocks/Blocks.js delete mode 100644 app/ui-message/client/blocks/ButtonElement.html delete mode 100644 app/ui-message/client/blocks/ModalBlock.html delete mode 100644 app/ui-message/client/blocks/TextBlock.html delete mode 100644 app/ui-message/client/blocks/TextBlock.js delete mode 100644 app/ui-message/client/blocks/styles.css delete mode 100644 app/ui/client/views/app/RoomForeword.html delete mode 100644 client/createTemplateForComponent.js create mode 100644 client/reactAdapters.js diff --git a/app/channel-settings/client/views/Multiselect.html b/app/channel-settings/client/views/Multiselect.html deleted file mode 100644 index 15e2b1884545a..0000000000000 --- a/app/channel-settings/client/views/Multiselect.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/app/channel-settings/client/views/Multiselect.js b/app/channel-settings/client/views/Multiselect.js index 160901096f147..f9400fa1b776c 100644 --- a/app/channel-settings/client/views/Multiselect.js +++ b/app/channel-settings/client/views/Multiselect.js @@ -1,23 +1,12 @@ -import './Multiselect.html'; -import { Template } from 'meteor/templating'; - -import { MultiSelectSettingInput } from '../../../../client/components/admin/settings/inputs/MultiSelectSettingInput'; - - -Template.Multiselect.onRendered(async function() { - const { MeteorProvider } = await import('../../../../client/providers/MeteorProvider'); - const React = await import('react'); - const ReactDOM = await import('react-dom'); - this.container = this.firstNode; - this.autorun(() => { - ReactDOM.render(React.createElement(MeteorProvider, { - children: React.createElement(MultiSelectSettingInput, Template.currentData()), - }), this.container); - }); -}); - - -Template.Multiselect.onDestroyed(async function() { - const ReactDOM = await import('react-dom'); - this.container && ReactDOM.unmountComponentAtNode(this.container); -}); +import { HTML } from 'meteor/htmljs'; + +import { createTemplateForComponent } from '../../../../client/reactAdapters'; + +createTemplateForComponent( + 'Multiselect', + () => import('../../../../client/components/admin/settings/inputs/MultiSelectSettingInput'), + { + // eslint-disable-next-line new-cap + renderContainerView: () => HTML.DIV({ class: 'rc-multiselect', style: 'display: flex;' }), + }, +); diff --git a/app/ui-message/client/blocks/Blocks.html b/app/ui-message/client/blocks/Blocks.html deleted file mode 100644 index 0d3495e14c53c..0000000000000 --- a/app/ui-message/client/blocks/Blocks.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/app/ui-message/client/blocks/Blocks.js b/app/ui-message/client/blocks/Blocks.js deleted file mode 100644 index 9adf2cc618334..0000000000000 --- a/app/ui-message/client/blocks/Blocks.js +++ /dev/null @@ -1,48 +0,0 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Template } from 'meteor/templating'; - -import * as ActionManager from '../ActionManager'; -import './Blocks.html'; -import { messageBlockWithContext } from './MessageBlock'; - - -Template.Blocks.onRendered(async function() { - const React = await import('react'); - const ReactDOM = await import('react-dom'); - const state = new ReactiveVar(); - this.autorun(() => { - state.set(Template.currentData()); - }); - - ReactDOM.render( - React.createElement(messageBlockWithContext({ - action: (options) => { - const { actionId, value, blockId, mid = this.data.mid } = options; - ActionManager.triggerBlockAction({ - blockId, - actionId, - value, - mid, - rid: this.data.rid, - appId: this.data.blocks[0].appId, - container: { - type: UIKitIncomingInteractionContainerType.MESSAGE, - id: mid, - }, - }); - }, - // state: alert, - appId: this.data.appId, - rid: this.data.rid, - }), { data: () => state.get() }), - this.firstNode, - ); - const event = new Event('rendered'); - this.firstNode.dispatchEvent(event); -}); - -Template.Blocks.onDestroyed(async function() { - const ReactDOM = await import('react-dom'); - this.firstNode && ReactDOM.unmountComponentAtNode(this.firstNode); -}); diff --git a/app/ui-message/client/blocks/ButtonElement.html b/app/ui-message/client/blocks/ButtonElement.html deleted file mode 100644 index 5711982a2e6ef..0000000000000 --- a/app/ui-message/client/blocks/ButtonElement.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/app/ui-message/client/blocks/MessageBlock.js b/app/ui-message/client/blocks/MessageBlock.js index 7830bafbb09e7..3bd9a8739ff30 100644 --- a/app/ui-message/client/blocks/MessageBlock.js +++ b/app/ui-message/client/blocks/MessageBlock.js @@ -1,17 +1,11 @@ -import React, { useRef, useEffect, useCallback, useMemo } from 'react'; -import { UiKitMessage as uiKitMessage, kitContext, UiKitModal as uiKitModal, messageParser, modalParser, UiKitComponent } from '@rocket.chat/fuselage-ui-kit'; -import { uiKitText } from '@rocket.chat/ui-kit'; -import { Modal, AnimatedVisibility, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; -import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; +import { UiKitMessage, UiKitComponent, kitContext, messageParser } from '@rocket.chat/fuselage-ui-kit'; +import React, { useRef, useEffect } from 'react'; import { renderMessageBody } from '../../../ui-utils/client'; -import { getURL } from '../../../utils/lib/getURL'; -import { useReactiveValue } from '../../../../client/hooks/useReactiveValue'; - -const focusableElementsString = 'a[href]:not([tabindex="-1"]), area[href]:not([tabindex="-1"]), input:not([disabled]):not([tabindex="-1"]), select:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]):not([tabindex="-1"]), button:not([disabled]):not([tabindex="-1"]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable]'; - -const focusableElementsStringInvalid = 'a[href]:not([tabindex="-1"]):invalid, area[href]:not([tabindex="-1"]):invalid, input:not([disabled]):not([tabindex="-1"]):invalid, select:not([disabled]):not([tabindex="-1"]):invalid, textarea:not([disabled]):not([tabindex="-1"]):invalid, button:not([disabled]):not([tabindex="-1"]):invalid, iframe:invalid, object:invalid, embed:invalid, [tabindex]:not([tabindex="-1"]):invalid, [contenteditable]:invalid'; +import * as ActionManager from '../ActionManager'; +// TODO: move this to fuselage-ui-kit itself messageParser.text = ({ text, type } = {}) => { if (type !== 'mrkdwn') { return text; @@ -20,165 +14,37 @@ messageParser.text = ({ text, type } = {}) => { return ; }; -modalParser.text = messageParser.text; - -const contextDefault = { - action: console.log, - state: (data) => { - console.log('state', data); - }, -}; -export const messageBlockWithContext = (context) => (props) => { - const data = useReactiveValue(props.data); - return ( - - {uiKitMessage(data.blocks)} - - ); -}; - -const textParser = uiKitText(new class { - plain_text({ text }) { - return text; - } - - text({ text }) { - return text; - } -}()); +export function MessageBlock({ mid: _mid, rid, blocks, appId }) { + const context = { + action: ({ actionId, value, blockId, mid = _mid }) => { + ActionManager.triggerBlockAction({ + blockId, + actionId, + value, + mid, + rid, + appId: blocks[0].appId, + container: { + type: UIKitIncomingInteractionContainerType.MESSAGE, + id: mid, + }, + }); + }, + appId, + rid, + }; -// https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html - -export const modalBlockWithContext = ({ - onSubmit, - onClose, - onCancel, - ...context -}) => (props) => { - const id = `modal_id_${ useUniqueId() }`; - - const { view, ...data } = useReactiveValue(props.data); - const values = useReactiveValue(props.values); const ref = useRef(); - - // Auto focus - useEffect(() => { - if (!ref.current) { - return; - } - - if (data.errors && Object.keys(data.errors).length) { - const element = ref.current.querySelector(focusableElementsStringInvalid); - element && element.focus(); - } else { - const element = ref.current.querySelector(focusableElementsString); - element && element.focus(); - } - }, [ref.current, data.errors]); - // save focus to restore after close - const previousFocus = useMemo(() => document.activeElement, []); - // restore the focus after the component unmount - useEffect(() => () => previousFocus && previousFocus.focus(), []); - // Handle Tab, Shift + Tab, Enter and Escape - const handleKeyDown = useCallback((event) => { - if (event.keyCode === 13) { // ENTER - return onSubmit(event); - } - - if (event.keyCode === 27) { // ESC - event.stopPropagation(); - event.preventDefault(); - onClose(); - return false; - } - - if (event.keyCode === 9) { // TAB - const elements = Array.from(ref.current.querySelectorAll(focusableElementsString)); - const [first] = elements; - const last = elements.pop(); - - if (!ref.current.contains(document.activeElement)) { - return first.focus(); - } - - if (event.shiftKey) { - if (!first || first === document.activeElement) { - last.focus(); - event.stopPropagation(); - event.preventDefault(); - } - return; - } - - if (!last || last === document.activeElement) { - first.focus(); - event.stopPropagation(); - event.preventDefault(); - } - } - }, [onSubmit]); - // Clean the events useEffect(() => { - const element = document.querySelector('.rc-modal-wrapper'); - const container = element.querySelector('.rcx-modal__content'); - const close = (e) => { - if (e.target !== element) { - return; - } - e.preventDefault(); - e.stopPropagation(); - onClose(); - return false; - }; - - const ignoreIfnotContains = (e) => { - if (!container.contains(e.target)) { - return; - } - return handleKeyDown(e); - }; - - document.addEventListener('keydown', ignoreIfnotContains); - element.addEventListener('click', close); - return () => { - document.removeEventListener('keydown', ignoreIfnotContains); - element.removeEventListener('click', close); - }; - }, handleKeyDown); + ref.current.dispatchEvent(new Event('rendered')); + }, []); return ( - - - - - - {textParser([view.title])} - - - - - - - - - - { view.close && } - { view.submit && } - - - - + +
+ ); -}; +} -export const MessageBlock = ({ blocks }, context = contextDefault) => ( - - {uiKitMessage(blocks)} - -); +export default MessageBlock; diff --git a/app/ui-message/client/blocks/ModalBlock.html b/app/ui-message/client/blocks/ModalBlock.html deleted file mode 100644 index 15f1f6f715f3d..0000000000000 --- a/app/ui-message/client/blocks/ModalBlock.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/app/ui-message/client/blocks/ModalBlock.js b/app/ui-message/client/blocks/ModalBlock.js index bb6a1e48deac4..cec19b7c74d1a 100644 --- a/app/ui-message/client/blocks/ModalBlock.js +++ b/app/ui-message/client/blocks/ModalBlock.js @@ -1,142 +1,349 @@ import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; -import { ReactiveDict } from 'meteor/reactive-dict'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Template } from 'meteor/templating'; +import { Modal, AnimatedVisibility, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; +import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { kitContext, UiKitComponent, UiKitModal, modalParser } from '@rocket.chat/fuselage-ui-kit'; +import { uiKitText } from '@rocket.chat/ui-kit'; +import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { renderMessageBody } from '../../../ui-utils/client'; +import { getURL } from '../../../utils/lib/getURL'; import * as ActionManager from '../ActionManager'; -import { modalBlockWithContext } from './MessageBlock'; -import './ModalBlock.html'; - -const prevent = (e) => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); +// TODO: move this to fuselage-ui-kit itself +modalParser.text = ({ text, type } = {}) => { + if (type !== 'mrkdwn') { + return text; } -}; -Template.ModalBlock.onRendered(async function() { - const React = await import('react'); - const ReactDOM = await import('react-dom'); - const state = new ReactiveVar(); + return ; +}; - const { viewId, appId } = this.data; +const textParser = uiKitText({ + plain_text: ({ text }) => text, + text: ({ text }) => text, +}); - this.autorun(() => { - state.set(Template.currentData()); - }); +const focusableElementsString = ` + a[href]:not([tabindex="-1"]), + area[href]:not([tabindex="-1"]), + input:not([disabled]):not([tabindex="-1"]), + select:not([disabled]):not([tabindex="-1"]), + textarea:not([disabled]):not([tabindex="-1"]), + button:not([disabled]):not([tabindex="-1"]), + iframe, + object, + embed, + [tabindex]:not([tabindex="-1"]), + [contenteditable]`; - const handleUpdate = ({ type, ...data }) => { - if (type === 'errors') { - return state.set({ ...state.get(), errors: data.errors }); - } - return state.set(data); - }; +const focusableElementsStringInvalid = ` + a[href]:not([tabindex="-1"]):invalid, + area[href]:not([tabindex="-1"]):invalid, + input:not([disabled]):not([tabindex="-1"]):invalid, + select:not([disabled]):not([tabindex="-1"]):invalid, + textarea:not([disabled]):not([tabindex="-1"]):invalid, + button:not([disabled]):not([tabindex="-1"]):invalid, + iframe:invalid, + object:invalid, + embed:invalid, + [tabindex]:not([tabindex="-1"]):invalid, + [contenteditable]:invalid`; - this.cancel = () => { - ActionManager.off(viewId, handleUpdate); - }; +export function ModalBlock({ + view, + errors, + appId, + onSubmit, + onClose, + onCancel, +}) { + const id = `modal_id_${ useUniqueId() }`; + const ref = useRef(); - this.node = this.find('.js-modal-block').parentElement; - ActionManager.on(viewId, handleUpdate); + // Auto focus + useEffect(() => { + if (!ref.current) { + return; + } - const filterInputFields = ({ element, elements = [] }) => { - if (element && element.initialValue) { - return true; + if (errors && Object.keys(errors).length) { + const element = ref.current.querySelector(focusableElementsStringInvalid); + element && element.focus(); + } else { + const element = ref.current.querySelector(focusableElementsString); + element && element.focus(); } - if (elements.length && elements.map((element) => ({ element })).filter(filterInputFields).length) { - return true; + }, [ref.current, errors]); + // save focus to restore after close + const previousFocus = useMemo(() => document.activeElement, []); + // restore the focus after the component unmount + useEffect(() => () => previousFocus && previousFocus.focus(), []); + // Handle Tab, Shift + Tab, Enter and Escape + const handleKeyDown = useCallback((event) => { + if (event.keyCode === 13) { // ENTER + return onSubmit(event); } - }; - const mapElementToState = ({ element, blockId, elements = [] }) => { - if (elements.length) { - return elements.map((element) => ({ element, blockId })).filter(filterInputFields).map(mapElementToState); + if (event.keyCode === 27) { // ESC + event.stopPropagation(); + event.preventDefault(); + onClose(); + return false; } - return [element.actionId, { value: element.initialValue, blockId }]; - }; - this.state = new ReactiveDict(this.data.view.blocks.filter(filterInputFields).map(mapElementToState).reduce((obj, el) => { - if (Array.isArray(el[0])) { - return { ...obj, ...Object.fromEntries(el) }; + if (event.keyCode === 9) { // TAB + const elements = Array.from(ref.current.querySelectorAll(focusableElementsString)); + const [first] = elements; + const last = elements.pop(); + + if (!ref.current.contains(document.activeElement)) { + return first.focus(); + } + + if (event.shiftKey) { + if (!first || first === document.activeElement) { + last.focus(); + event.stopPropagation(); + event.preventDefault(); + } + return; + } + + if (!last || last === document.activeElement) { + first.focus(); + event.stopPropagation(); + event.preventDefault(); + } } - return { ...obj, [el[0]]: el[1] }; - }, {})); + }, [onSubmit]); + // Clean the events + useEffect(() => { + const element = document.querySelector('.rc-modal-wrapper'); + const container = element.querySelector('.rcx-modal__content'); + const close = (e) => { + if (e.target !== element) { + return; + } + e.preventDefault(); + e.stopPropagation(); + onClose(); + return false; + }; + + const ignoreIfnotContains = (e) => { + if (!container.contains(e.target)) { + return; + } + return handleKeyDown(e); + }; + + document.addEventListener('keydown', ignoreIfnotContains); + element.addEventListener('click', close); + return () => { + document.removeEventListener('keydown', ignoreIfnotContains); + element.removeEventListener('click', close); + }; + }, handleKeyDown); + + return ( + + + + + {textParser([view.title])} + + + + + + + + + + {view.close && } + {view.submit && } + + + + + ); +} + +const useActionManagerState = (initialState) => { + const [state, setState] = useState(initialState); + + const { viewId } = state; + + useEffect(() => { + const handleUpdate = ({ type, ...data }) => { + if (type === 'errors') { + const { errors } = data; + setState({ ...state, errors }); + return; + } + + setState(data); + }; + + ActionManager.on(viewId, handleUpdate); + + return () => { + ActionManager.off(viewId, handleUpdate); + }; + }, [viewId]); + + return state; +}; + +const useValues = (view) => { + const reducer = useMutableCallback((values, { actionId, payload }) => ({ + ...values, + [actionId]: payload, + })); + + const initializer = useMutableCallback(() => { + const filterInputFields = ({ element, elements = [] }) => { + if (element && element.initialValue) { + return true; + } + + if (elements.length && elements.map((element) => ({ element })).filter(filterInputFields).length) { + return true; + } + }; + + const mapElementToState = ({ element, blockId, elements = [] }) => { + if (elements.length) { + return elements.map((element) => ({ element, blockId })).filter(filterInputFields).map(mapElementToState); + } + return [element.actionId, { value: element.initialValue, blockId }]; + }; - const groupStateByBlockIdMap = (obj, [key, { blockId, value }]) => { + return view.blocks + .filter(filterInputFields) + .map(mapElementToState) + .reduce((obj, el) => { + if (Array.isArray(el[0])) { + return { ...obj, ...Object.fromEntries(el) }; + } + + const [key, value] = el; + return { ...obj, [key]: value }; + }, {}); + }); + + return useReducer(reducer, null, initializer); +}; + +function ConnectedModalBlock(props) { + const state = useActionManagerState(props); + + const { + appId, + viewId, + mid: _mid, + errors, + view, + } = state; + + const [values, updateValues] = useValues(view); + + const groupStateByBlockId = (obj) => Object.entries(obj).reduce((obj, [key, { blockId, value }]) => { obj[blockId] = obj[blockId] || {}; obj[blockId][key] = value; return obj; + }, {}); + + const prevent = (e) => { + if (e) { + (e.nativeEvent || e).stopImmediatePropagation(); + e.stopPropagation(); + e.preventDefault(); + } }; - const groupStateByBlockId = (obj) => Object.entries(obj).reduce(groupStateByBlockIdMap, {}); - ReactDOM.render( - React.createElement( - modalBlockWithContext({ - onCancel: (e) => { - prevent(e); - return ActionManager.triggerCancel({ - appId, - viewId, - view: { - ...state.get().view, - id: viewId, - state: groupStateByBlockId(this.state.all()), - }, - }); - }, - onClose: (e) => { - prevent(e); - return ActionManager.triggerCancel({ - appId, - viewId, - view: { - ...state.get().view, - id: viewId, - state: groupStateByBlockId(this.state.all()), - }, - isCleared: true, - }); - }, - onSubmit: (e) => { - prevent(e); - ActionManager.triggerSubmitView({ - viewId, - appId, - payload: { - view: { - ...state.get().view, - id: viewId, - state: groupStateByBlockId(this.state.all()), - }, - }, - }); - }, - action: ({ actionId, appId, value, blockId, mid = this.data.mid }) => ActionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: viewId, - }, - actionId, - appId, - value, + + const context = { + action: ({ actionId, appId, value, blockId, mid = _mid }) => ActionManager.triggerBlockAction({ + container: { + type: UIKitIncomingInteractionContainerType.VIEW, + id: viewId, + }, + actionId, + appId, + value, + blockId, + mid, + }), + state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { + updateValues({ + actionId, + payload: { blockId, - mid, - }), - state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { - this.state.set(actionId, { - blockId, - value, - }); + value, }, - ...this.data, - }), - { data: () => state.get(), values: () => this.state.all() }, - ), - this.node, - ); -}); -Template.ModalBlock.onDestroyed(async function() { - const ReactDOM = await import('react-dom'); - this.node && ReactDOM.unmountComponentAtNode(this.node); -}); + }); + }, + ...state, + values, + }; + + const handleSubmit = useMutableCallback((e) => { + prevent(e); + ActionManager.triggerSubmitView({ + viewId, + appId, + payload: { + view: { + ...view, + id: viewId, + state: groupStateByBlockId(values), + }, + }, + }); + }); + + const handleCancel = useMutableCallback((e) => { + prevent(e); + return ActionManager.triggerCancel({ + appId, + viewId, + view: { + ...view, + id: viewId, + state: groupStateByBlockId(values), + }, + }); + }); + + const handleClose = useMutableCallback((e) => { + prevent(e); + return ActionManager.triggerCancel({ + appId, + viewId, + view: { + ...view, + id: viewId, + state: groupStateByBlockId(values), + }, + isCleared: true, + }); + }); + + return + + ; +} + +export default ConnectedModalBlock; diff --git a/app/ui-message/client/blocks/TextBlock.html b/app/ui-message/client/blocks/TextBlock.html deleted file mode 100644 index 9ede6447c0b51..0000000000000 --- a/app/ui-message/client/blocks/TextBlock.html +++ /dev/null @@ -1,12 +0,0 @@ - - diff --git a/app/ui-message/client/blocks/TextBlock.js b/app/ui-message/client/blocks/TextBlock.js deleted file mode 100644 index e71c590301ee5..0000000000000 --- a/app/ui-message/client/blocks/TextBlock.js +++ /dev/null @@ -1,5 +0,0 @@ - - -// import { Template } from 'meteor/templating'; - -import './TextBlock.html'; diff --git a/app/ui-message/client/blocks/index.js b/app/ui-message/client/blocks/index.js index 2b64c4de523cc..5e332705c1d40 100644 --- a/app/ui-message/client/blocks/index.js +++ b/app/ui-message/client/blocks/index.js @@ -1,5 +1,4 @@ -import './styles.css'; -import './Blocks.js'; -import './ModalBlock'; -import './TextBlock'; -import './ButtonElement.html'; +import { createTemplateForComponent } from '../../../../client/reactAdapters'; + +createTemplateForComponent('ModalBlock', () => import('./ModalBlock')); +createTemplateForComponent('Blocks', () => import('./MessageBlock')); diff --git a/app/ui-message/client/blocks/styles.css b/app/ui-message/client/blocks/styles.css deleted file mode 100644 index 2f990fc2f0453..0000000000000 --- a/app/ui-message/client/blocks/styles.css +++ /dev/null @@ -1,18 +0,0 @@ -.block-kit-debug.debug { - padding: 1rem; - - border: 1px solid; -} - -.block-kit-debug legend { - display: none; -} - -.block-kit-debug.debug legend { - display: initial; -} - -.rc-modal--uikit { - width: 680px; - max-width: 100%; -} diff --git a/app/ui-sidenav/client/SortList.js b/app/ui-sidenav/client/SortList.js index 75fbc3221115f..b0add853e97a1 100644 --- a/app/ui-sidenav/client/SortList.js +++ b/app/ui-sidenav/client/SortList.js @@ -129,3 +129,5 @@ function GroupingList() { ; } + +export default SortList; diff --git a/app/ui-sidenav/client/sidebarHeader.js b/app/ui-sidenav/client/sidebarHeader.js index 306872c0a4562..b748f03678358 100644 --- a/app/ui-sidenav/client/sidebarHeader.js +++ b/app/ui-sidenav/client/sidebarHeader.js @@ -10,7 +10,7 @@ import { settings } from '../../settings'; import { hasAtLeastOnePermission } from '../../authorization'; import { userStatus } from '../../user-status'; import { hasPermission } from '../../authorization/client'; -import { createTemplateForComponent } from '../../../client/createTemplateForComponent'; +import { createTemplateForComponent } from '../../../client/reactAdapters'; const setStatus = (status, statusText) => { @@ -66,15 +66,13 @@ const toolbarButtons = (/* user */) => [{ action: async (e) => { const options = []; const config = { - template: 'SortList', + template: createTemplateForComponent('SortList', () => import('./SortList')), currentTarget: e.currentTarget, data: { options, }, offsetVertical: e.currentTarget.clientHeight + 10, }; - const { SortList } = await import('./SortList'); - await createTemplateForComponent(SortList); popover.open(config); }, }, diff --git a/app/ui-utils/client/lib/callMethod.js b/app/ui-utils/client/lib/callMethod.js index 390c1ea21bc89..c1b05108ad806 100644 --- a/app/ui-utils/client/lib/callMethod.js +++ b/app/ui-utils/client/lib/callMethod.js @@ -1,6 +1,6 @@ import { Meteor } from 'meteor/meteor'; -import { handleError } from '../../../utils'; +import { handleError } from '../../../utils/client/lib/handleError'; /** * Wraps a Meteor method into a Promise. diff --git a/app/ui/client/index.js b/app/ui/client/index.js index 4867309779f87..5d70fb1747b9c 100644 --- a/app/ui/client/index.js +++ b/app/ui/client/index.js @@ -1,3 +1,5 @@ +import { createTemplateForComponent } from '../../../client/reactAdapters'; + import './lib/accounts'; import './lib/collections'; import './lib/customEventPolyfill'; @@ -43,7 +45,6 @@ import './views/app/invite'; import './views/app/videoCall/videoButtons'; import './views/app/videoCall/videoCall'; import './views/app/photoswipe'; -import './views/app/RoomForeword'; import './components/icon'; import './components/status'; import './components/table.html'; @@ -67,3 +68,5 @@ export { Login, animationSupport, animeBack, Button, preLoadImgs } from './lib/r export { AudioRecorder } from './lib/recorderjs/audioRecorder'; export { VideoRecorder } from './lib/recorderjs/videoRecorder'; export { chatMessages } from './views/app/room'; + +createTemplateForComponent('RoomForeword', () => import('./views/app/RoomForeword')); diff --git a/app/ui/client/views/app/RoomForeword.html b/app/ui/client/views/app/RoomForeword.html deleted file mode 100644 index 72b2b7734c0ef..0000000000000 --- a/app/ui/client/views/app/RoomForeword.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/app/ui/client/views/app/RoomForeword.js b/app/ui/client/views/app/RoomForeword.js index ef068e3b32191..e9aee84793f1a 100644 --- a/app/ui/client/views/app/RoomForeword.js +++ b/app/ui/client/views/app/RoomForeword.js @@ -1,16 +1,17 @@ import React from 'react'; -import { Meteor } from 'meteor/meteor'; import { Avatar, Margins, Flex, Box, Tag } from '@rocket.chat/fuselage'; -import { Template } from 'meteor/templating'; -import './RoomForeword.html'; -import { Rooms, Users } from '../../../../models'; +import { Rooms } from '../../../../models'; import { useTranslation } from '../../../../../client/contexts/TranslationContext'; +import { useReactiveValue } from '../../../../../client/hooks/useReactiveValue'; +import { useUser } from '../../../../../client/contexts/UserContext'; - -const RoomForeword = ({ room, user }) => { +const RoomForeword = ({ _id: rid }) => { const t = useTranslation(); + const user = useUser(); + const room = useReactiveValue(() => Rooms.findOne({ _id: rid })); + if (room.t !== 'd') { return t('Start_of_conversation'); } @@ -39,7 +40,15 @@ const RoomForeword = ({ room, user }) => { - {users.map((username, index) => {username})} + {users.map((username, index) => + {username} + )} @@ -49,25 +58,4 @@ const RoomForeword = ({ room, user }) => { ; }; -Template.RoomForeword.onRendered(async function() { - const { MeteorProvider } = await import('../../../../../client/providers/MeteorProvider'); - const ReactDOM = await import('react-dom'); - this.container = this.firstNode; - this.autorun(() => { - const data = Template.currentData(); - const { _id: rid } = data; - - const user = Users.findOne(Meteor.userId(), { username: 1 }); - - const room = Rooms.findOne({ _id: rid }); - ReactDOM.render(React.createElement(MeteorProvider, { - children: React.createElement(RoomForeword, { ...data, room, user }), - }), this.container); - }); -}); - - -Template.RoomForeword.onDestroyed(async function() { - const ReactDOM = await import('react-dom'); - this.container && ReactDOM.unmountComponentAtNode(this.container); -}); +export default RoomForeword; diff --git a/app/ui/client/views/app/components/Directory/index.js b/app/ui/client/views/app/components/Directory/index.js index 5d56e61265a5a..9788b7fb11e35 100644 --- a/app/ui/client/views/app/components/Directory/index.js +++ b/app/ui/client/views/app/components/Directory/index.js @@ -49,3 +49,5 @@ export function DirectoryPage() { DirectoryPage.displayName = 'DirectoryPage'; + +export default DirectoryPage; diff --git a/client/components/admin/info/InformationRoute.js b/client/components/admin/info/InformationRoute.js index 455b7c1fd826e..b3c2d7f7097b7 100644 --- a/client/components/admin/info/InformationRoute.js +++ b/client/components/admin/info/InformationRoute.js @@ -82,3 +82,5 @@ export function InformationRoute() { onClickDownloadInfo={handleClickDownloadInfo} />; } + +export default InformationRoute; diff --git a/client/components/admin/settings/GroupPage.js b/client/components/admin/settings/GroupPage.js index 75ff9f5213da9..dbe85bdb5def1 100644 --- a/client/components/admin/settings/GroupPage.js +++ b/client/components/admin/settings/GroupPage.js @@ -1,4 +1,4 @@ -import { Accordion, Box, Button, ButtonGroup, Paragraph, Skeleton } from '@rocket.chat/fuselage'; +import { Accordion, Box, Button, ButtonGroup, Skeleton } from '@rocket.chat/fuselage'; import React, { useMemo } from 'react'; import { useTranslation } from '../../../contexts/TranslationContext'; @@ -48,7 +48,7 @@ export function GroupPage({ children, headerButtons, save, cancel, _id, i18nLabe ({ margin: '0 auto', width: '100%', maxWidth: '590px' }), [])}> - {t.has(i18nDescription) && {t(i18nDescription)}} + {t.has(i18nDescription) && {t(i18nDescription)}} {children} @@ -74,7 +74,11 @@ export function GroupPageSkeleton() { ({ margin: '0 auto', width: '100%', maxWidth: '590px' }), [])}> - + + + + + diff --git a/client/components/admin/settings/NotAuthorizedPage.js b/client/components/admin/settings/NotAuthorizedPage.js index cf62068ed5551..bfdc64581ff75 100644 --- a/client/components/admin/settings/NotAuthorizedPage.js +++ b/client/components/admin/settings/NotAuthorizedPage.js @@ -1,4 +1,4 @@ -import { Paragraph } from '@rocket.chat/fuselage'; +import { Box } from '@rocket.chat/fuselage'; import React from 'react'; import { useTranslation } from '../../../contexts/TranslationContext'; @@ -9,7 +9,7 @@ export function NotAuthorizedPage() { return - {t('You_are_not_authorized_to_view_this_page')} + {t('You_are_not_authorized_to_view_this_page')} ; } diff --git a/client/components/admin/settings/Section.js b/client/components/admin/settings/Section.js index 68a8ef5cc88a5..9eb91677bfc60 100644 --- a/client/components/admin/settings/Section.js +++ b/client/components/admin/settings/Section.js @@ -1,4 +1,4 @@ -import { Accordion, Box, Button, FieldGroup, Paragraph, Skeleton } from '@rocket.chat/fuselage'; +import { Accordion, Box, Button, FieldGroup, Skeleton } from '@rocket.chat/fuselage'; import React from 'react'; import { useTranslation } from '../../../contexts/TranslationContext'; @@ -20,9 +20,7 @@ export function Section({ children, groupId, hasReset = true, help, sectionName, noncollapsible={solo || !section.name} title={section.name && t(section.name)} > - {help && - {help} - } + {help && {help}} {section.settings.map((settingId) => )} @@ -41,13 +39,10 @@ export function Section({ children, groupId, hasReset = true, help, sectionName, } export function SectionSkeleton() { - return } - > - + return }> + - + {Array.from({ length: 10 }).map((_, i) => )} diff --git a/client/components/admin/settings/SettingsRoute.js b/client/components/admin/settings/SettingsRoute.js index d53e3a1792ca3..831d620abb9b0 100644 --- a/client/components/admin/settings/SettingsRoute.js +++ b/client/components/admin/settings/SettingsRoute.js @@ -5,10 +5,9 @@ import { useAdminSideNav } from '../../../hooks/useAdminSideNav'; import { GroupSelector } from './GroupSelector'; import { NotAuthorizedPage } from './NotAuthorizedPage'; import { SettingsState } from './SettingsState'; +import { useRouteParameter } from '../../../contexts/RouterContext'; -export function SettingsRoute({ - group: groupId, -}) { +export function SettingsRoute() { useAdminSideNav(); const hasPermission = useAtLeastOnePermission([ @@ -17,6 +16,8 @@ export function SettingsRoute({ 'manage-selected-settings', ]); + const groupId = useRouteParameter('group'); + if (!hasPermission) { return ; } @@ -25,3 +26,5 @@ export function SettingsRoute({ ; } + +export default SettingsRoute; diff --git a/client/components/admin/settings/inputs/MultiSelectSettingInput.js b/client/components/admin/settings/inputs/MultiSelectSettingInput.js index 538b4a74c4c2a..8c7e887380804 100644 --- a/client/components/admin/settings/inputs/MultiSelectSettingInput.js +++ b/client/components/admin/settings/inputs/MultiSelectSettingInput.js @@ -56,3 +56,5 @@ export function MultiSelectSettingInput({ ); } + +export default MultiSelectSettingInput; diff --git a/client/components/admin/settings/inputs/StringSettingInput.js b/client/components/admin/settings/inputs/StringSettingInput.js index 8c3ec25306df6..e20a3c8d482d1 100644 --- a/client/components/admin/settings/inputs/StringSettingInput.js +++ b/client/components/admin/settings/inputs/StringSettingInput.js @@ -1,4 +1,4 @@ -import { Box, Field, Flex, TextAreaInput, TextInput, Icon, Callout, Margins } from '@rocket.chat/fuselage'; +import { Box, Field, Flex, TextAreaInput, TextInput, Callout, Margins } from '@rocket.chat/fuselage'; import React from 'react'; import { ResetSettingButton } from '../ResetSettingButton'; @@ -24,8 +24,6 @@ export function StringSettingInput({ hasResetButton, onChangeValue, onResetButtonClick, - addon, - ...props }) { const handleChange = (event) => { onChangeValue(event.currentTarget.value); @@ -50,8 +48,6 @@ export function StringSettingInput({ readOnly={readonly} autoComplete={autocomplete === false ? 'off' : undefined} onChange={handleChange} - { ...addon && { addon: }} - { ...props } /> : }} - { ...props } /> } ; diff --git a/client/components/pageNotFound/PageNotFound.js b/client/components/pageNotFound/PageNotFound.js index bd7b2f9487513..d3daa7b64415c 100644 --- a/client/components/pageNotFound/PageNotFound.js +++ b/client/components/pageNotFound/PageNotFound.js @@ -51,3 +51,5 @@ export function PageNotFound() { ; } + +export default PageNotFound; diff --git a/client/components/setupWizard/SetupWizardRoute.js b/client/components/setupWizard/SetupWizardRoute.js index 273300195ef5c..60958e5385d52 100644 --- a/client/components/setupWizard/SetupWizardRoute.js +++ b/client/components/setupWizard/SetupWizardRoute.js @@ -49,3 +49,5 @@ export function SetupWizardRoute() { return ; } + +export default SetupWizardRoute; diff --git a/client/createTemplateForComponent.js b/client/createTemplateForComponent.js deleted file mode 100644 index 423964471174e..0000000000000 --- a/client/createTemplateForComponent.js +++ /dev/null @@ -1,66 +0,0 @@ -import { Blaze } from 'meteor/blaze'; -import { HTML } from 'meteor/htmljs'; -import { FlowRouter } from 'meteor/kadira:flow-router'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Template } from 'meteor/templating'; - - -export const createTemplateForComponent = async ( - component, - props = {}, - // eslint-disable-next-line new-cap - renderContainerView = () => HTML.DIV(), - url, - -) => { - const name = component.displayName || component.name; - - if (!name) { - throw new Error('the component must have a name'); - } - - if (Template[name]) { - Template[name].props.set(props); - return name; - } - - Template[name] = new Blaze.Template(name, renderContainerView); - - Template[name].props = new ReactiveVar(props); - - const React = await import('react'); - const ReactDOM = await import('react-dom'); - const { MeteorProvider } = await import('./providers/MeteorProvider'); - - function TemplateComponent() { - return React.createElement(component, Template[name].props.get()); - } - - Template[name].onRendered(() => { - Template.instance().autorun((computation) => { - if (computation.firstRun) { - Template.instance().container = Template.instance().firstNode; - } - - ReactDOM.render( - React.createElement(MeteorProvider, { - children: React.createElement(TemplateComponent), - }), Template.instance().firstNode); - }); - - url && Template.instance().autorun(() => { - const routeName = FlowRouter.getRouteName(); - if (routeName !== url) { - ReactDOM.unmountComponentAtNode(Template.instance().container); - } - }); - }); - - Template[name].onDestroyed(() => { - if (Template.instance().container) { - ReactDOM.unmountComponentAtNode(Template.instance().container); - } - }); - - return name; -}; diff --git a/client/providers/MeteorProvider.js b/client/providers/MeteorProvider.js index 0471a1c427e36..2b4056bff0a65 100644 --- a/client/providers/MeteorProvider.js +++ b/client/providers/MeteorProvider.js @@ -34,3 +34,5 @@ export function MeteorProvider({ children }) { ; } + +export default MeteorProvider; diff --git a/client/reactAdapters.js b/client/reactAdapters.js new file mode 100644 index 0000000000000..54703f2b95ca7 --- /dev/null +++ b/client/reactAdapters.js @@ -0,0 +1,183 @@ +import { Blaze } from 'meteor/blaze'; +import { HTML } from 'meteor/htmljs'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; + +let rootNode; +let invalidatePortals = () => {}; +const portalsMap = new Map(); + +const mountRoot = async () => { + rootNode = document.getElementById('react-root'); + + if (!rootNode) { + rootNode = document.createElement('div'); + rootNode.id = 'react-root'; + document.body.appendChild(rootNode); + } + + const [ + { Suspense, createElement, lazy, useState }, + { render }, + ] = await Promise.all([ + import('react'), + import('react-dom'), + ]); + + const LazyMeteorProvider = lazy(() => import('./providers/MeteorProvider')); + + function AppRoot() { + const [portals, setPortals] = useState(() => Tracker.nonreactive(() => Array.from(portalsMap.values()))); + invalidatePortals = () => { + setPortals(Array.from(portalsMap.values())); + }; + + return createElement(Suspense, { fallback: null }, + createElement(LazyMeteorProvider, {}, ...portals), + ); + } + + render(createElement(AppRoot), rootNode); +}; + +export const registerPortal = (key, portal) => { + if (!rootNode) { + mountRoot(); + } + + portalsMap.set(key, portal); + invalidatePortals(); +}; + +export const unregisterPortal = (key) => { + portalsMap.delete(key); + invalidatePortals(); +}; + +const createLazyElement = async (importFn, propsFn) => { + const { createElement, lazy, useEffect, useState } = await import('react'); + const LazyComponent = lazy(importFn); + + if (!propsFn) { + return createElement(LazyComponent); + } + + const WrappedComponent = () => { + const [props, setProps] = useState(() => Tracker.nonreactive(propsFn)); + + useEffect(() => { + const computation = Tracker.autorun(() => { + setProps(propsFn); + }); + + return () => { + computation.stop(); + }; + }, []); + + return createElement(LazyComponent, props); + }; + + return createElement(WrappedComponent); +}; + +const createLazyPortal = async (importFn, propsFn, node) => { + const { createPortal } = await import('react-dom'); + return createPortal(await createLazyElement(importFn, propsFn), node); +}; + +export const createTemplateForComponent = ( + name, + importFn, + { + renderContainerView = () => HTML.DIV(), // eslint-disable-line new-cap + } = {}, +) => { + if (Template[name]) { + return name; + } + + const template = new Blaze.Template(name, renderContainerView); + + template.onRendered(async function() { + const props = new ReactiveVar(this.data); + this.autorun(() => { + props.set(Template.currentData()); + }); + + const portal = await createLazyPortal(importFn, () => props.get(), this.firstNode); + + if (!this.firstNode) { + return; + } + + registerPortal(this, portal); + }); + + template.onDestroyed(function() { + unregisterPortal(this); + }); + + Template[name] = template; + + return name; +}; + +export const renderRouteComponent = (importFn, { + template, + region, + renderContainerView = () => HTML.DIV(), // eslint-disable-line new-cap +} = {}) => { + const routeName = FlowRouter.getRouteName(); + + Tracker.autorun(async (computation) => { + if (routeName !== FlowRouter.getRouteName()) { + unregisterPortal(routeName); + computation.stop(); + return; + } + + if (!computation.firstRun) { + return; + } + + if (!template || !region) { + BlazeLayout.reset(); + + const element = await createLazyElement(importFn); + + if (routeName !== FlowRouter.getRouteName()) { + return; + } + + registerPortal(routeName, element); + return; + } + + if (!Template[routeName]) { + const blazeTemplate = new Blaze.Template(routeName, renderContainerView); + + blazeTemplate.onRendered(async function() { + const props = new ReactiveVar(this.data); + this.autorun(() => { + props.set(Template.currentData()); + }); + + const portal = await createLazyPortal(importFn, () => props.get(), this.firstNode); + + if (routeName !== FlowRouter.getRouteName()) { + return; + } + + registerPortal(routeName, portal); + }); + + Template[routeName] = blazeTemplate; + } + + BlazeLayout.render(template, { [region]: routeName }); + }); +}; diff --git a/client/routes.js b/client/routes.js index d616b5deb29ff..5e29798f30b40 100644 --- a/client/routes.js +++ b/client/routes.js @@ -1,9 +1,9 @@ import mem from 'mem'; import s from 'underscore.string'; +import { HTML } from 'meteor/htmljs'; import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Tracker } from 'meteor/tracker'; -import { HTML } from 'meteor/htmljs'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { BlazeLayout } from 'meteor/kadira:blaze-layout'; import { Session } from 'meteor/session'; @@ -13,7 +13,7 @@ import { KonchatNotification } from '../app/ui'; import { ChatSubscription } from '../app/models'; import { roomTypes, handleError } from '../app/utils'; import { call } from '../app/ui-utils'; -import { createTemplateForComponent } from './createTemplateForComponent'; +import { renderRouteComponent } from './reactAdapters'; const getRoomById = mem((rid) => call('getRoomById', rid)); @@ -95,10 +95,8 @@ FlowRouter.route('/home', { FlowRouter.route('/directory/:tab?', { name: 'directory', - - async action() { - const { DirectoryPage } = await require('../app/ui/client/views/app/components/Directory'); - BlazeLayout.render('main', { center: await createTemplateForComponent(DirectoryPage, { }, () => HTML.DIV({ style }), 'directory')}); // eslint-disable-line + action: () => { + renderRouteComponent(() => import('../app/ui/client/views/app/components/Directory'), { template: 'main', region: 'center' }); }, triggersExit: [function() { $('.main-content').addClass('rc-old'); @@ -107,13 +105,10 @@ FlowRouter.route('/directory/:tab?', { FlowRouter.route('/account/:group?', { name: 'account', - - async action(params) { + action: (params) => { if (!params.group) { params.group = 'Profile'; } - const { Input } = await require('../client/components/admin/settings/inputs/StringSettingInput'); - console.log(await createTemplateForComponent(Input, { }, () => HTML.DIV({ style }))); // eslint-disable-line params.group = s.capitalize(params.group, true); BlazeLayout.render('main', { center: `account${ params.group }` }); }, @@ -124,8 +119,7 @@ FlowRouter.route('/account/:group?', { FlowRouter.route('/terms-of-service', { name: 'terms-of-service', - - action() { + action: () => { Session.set('cmsPage', 'Layout_Terms_of_Service'); BlazeLayout.render('cmsPage'); }, @@ -133,8 +127,7 @@ FlowRouter.route('/terms-of-service', { FlowRouter.route('/privacy-policy', { name: 'privacy-policy', - - action() { + action: () => { Session.set('cmsPage', 'Layout_Privacy_Policy'); BlazeLayout.render('cmsPage'); }, @@ -142,8 +135,7 @@ FlowRouter.route('/privacy-policy', { FlowRouter.route('/legal-notice', { name: 'legal-notice', - - action() { + action: () => { Session.set('cmsPage', 'Layout_Legal_Notice'); BlazeLayout.render('cmsPage'); }, @@ -151,72 +143,59 @@ FlowRouter.route('/legal-notice', { FlowRouter.route('/room-not-found/:type/:name', { name: 'room-not-found', - - action(params) { - Session.set('roomNotFound', { type: params.type, name: params.name }); + action: ({ type, name }) => { + Session.set('roomNotFound', { type, name }); BlazeLayout.render('main', { center: 'roomNotFound' }); }, }); FlowRouter.route('/register/:hash', { name: 'register-secret-url', - - action(/* params*/) { + action: () => { BlazeLayout.render('secretURL'); - - // if RocketChat.settings.get('Accounts_RegistrationForm') is 'Secret URL' - // Meteor.call 'checkRegistrationSecretURL', params.hash, (err, success) -> - // if success - // Session.set 'loginDefaultState', 'register' - // BlazeLayout.render 'main', {center: 'home'} - // KonchatNotification.getDesktopPermission() - // else - // BlazeLayout.render 'logoLayout', { render: 'invalidSecretURL' } - // else - // BlazeLayout.render 'logoLayout', { render: 'invalidSecretURL' } }, }); FlowRouter.route('/invite/:hash', { name: 'invite', - - action(/* params */) { + action: () => { BlazeLayout.render('invite'); }, }); FlowRouter.route('/setup-wizard/:step?', { name: 'setup-wizard', - action: async () => { - const { SetupWizardRoute } = await import('./components/setupWizard/SetupWizardRoute'); - BlazeLayout.render(await createTemplateForComponent(SetupWizardRoute)); + action: () => { + renderRouteComponent(() => import('./components/setupWizard/SetupWizardRoute')); }, }); -const style = 'overflow: hidden; flex: 1 1 auto; height: 1%;'; +FlowRouter.route('/admin/info', { + name: 'admin-info', + action: () => { + renderRouteComponent(() => import('./components/admin/info/InformationRoute'), { + template: 'main', + region: 'center', + // eslint-disable-next-line new-cap + renderContainerView: () => HTML.DIV({ style: 'overflow: hidden; flex: 1 1 auto; height: 1%;' }), + }); + }, +}); FlowRouter.route('/admin/:group?', { name: 'admin', - action: async ({ group = 'info' } = {}) => { - switch (group) { - case 'info': { - const { InformationRoute } = await import('./components/admin/info/InformationRoute'); - BlazeLayout.render('main', { center: await createTemplateForComponent(InformationRoute, { }, () => HTML.DIV({ style })) }); // eslint-disable-line - break; - } - - default: { - const { SettingsRoute } = await import('./components/admin/settings/SettingsRoute'); - BlazeLayout.render('main', { center: await createTemplateForComponent(SettingsRoute, { group }, () => HTML.DIV({ style })) }); // eslint-disable-line - // BlazeLayout.render('main', { center: 'admin' }); - } - } + action: () => { + renderRouteComponent(() => import('./components/admin/settings/SettingsRoute'), { + template: 'main', + region: 'center', + // eslint-disable-next-line new-cap + renderContainerView: () => HTML.DIV({ style: 'overflow: hidden; flex: 1 1 auto; height: 1%;' }), + }); }, }); FlowRouter.notFound = { - action: async () => { - const { PageNotFound } = await import('./components/pageNotFound/PageNotFound'); - BlazeLayout.render(await createTemplateForComponent(PageNotFound)); + action: () => { + renderRouteComponent(() => import('./components/pageNotFound/PageNotFound')); }, }; diff --git a/ee/app/engagement-dashboard/client/components/EngagementDashboardRoute.js b/ee/app/engagement-dashboard/client/components/EngagementDashboardRoute.js index 1761ddfdd81c0..c9a85ce40f465 100644 --- a/ee/app/engagement-dashboard/client/components/EngagementDashboardRoute.js +++ b/ee/app/engagement-dashboard/client/components/EngagementDashboardRoute.js @@ -22,4 +22,5 @@ export function EngagementDashboardRoute() { onSelectTab={(tab) => goToEngagementDashboard({ tab })} />; } -EngagementDashboardRoute.displayName = 'EngagementDashboardRoute'; + +export default EngagementDashboardRoute; diff --git a/ee/app/engagement-dashboard/client/routes.js b/ee/app/engagement-dashboard/client/routes.js index f8735786bbaf3..f99bd92afc6c1 100644 --- a/ee/app/engagement-dashboard/client/routes.js +++ b/ee/app/engagement-dashboard/client/routes.js @@ -1,13 +1,9 @@ -import { HTML } from 'meteor/htmljs'; import { FlowRouter } from 'meteor/kadira:flow-router'; -import { BlazeLayout } from 'meteor/kadira:blaze-layout'; - import { hasAllPermission } from '../../../../app/authorization'; import { AdminBox } from '../../../../app/ui-utils'; +import { renderRouteComponent } from '../../../../client/reactAdapters'; import { hasLicense } from '../../license/client'; -import { createTemplateForComponent } from '../../../../client/createTemplateForComponent'; - FlowRouter.route('/admin/engagement-dashboard/:tab?', { name: 'engagement-dashboard', @@ -17,14 +13,7 @@ FlowRouter.route('/admin/engagement-dashboard/:tab?', { return; } - const { EngagementDashboardRoute } = await import('./components/EngagementDashboardRoute'); - - BlazeLayout.render('main', { center: await createTemplateForComponent(EngagementDashboardRoute, - {}, - // eslint-disable-next-line new-cap - () => HTML.DIV.call(null, { style: 'overflow: hidden; flex: 1 1 auto; height: 1%;' }), - 'engagement-dashboard'), - }); + renderRouteComponent(() => import('./components/EngagementDashboardRoute'), { template: 'main', region: 'center' }); }, });