diff --git a/.circleci/sign.key.gpg b/.circleci/sign.key.gpg index 488e275998d5..6d005764c11b 100644 Binary files a/.circleci/sign.key.gpg and b/.circleci/sign.key.gpg differ diff --git a/.docker/Dockerfile.rhel b/.docker/Dockerfile.rhel index c89ca0c9c9ce..3c225ef80951 100644 --- a/.docker/Dockerfile.rhel +++ b/.docker/Dockerfile.rhel @@ -1,6 +1,6 @@ FROM registry.access.redhat.com/rhscl/nodejs-8-rhel7 -ENV RC_VERSION 1.2.1 +ENV RC_VERSION 1.3.0 MAINTAINER buildmaster@rocket.chat diff --git a/.github/history.json b/.github/history.json index 72672adc6be2..b7ca1acf8b06 100644 --- a/.github/history.json +++ b/.github/history.json @@ -32505,6 +32505,665 @@ } ] }, + "1.3.0-rc.0": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "14954", + "title": "[NEW] Show helpful error when oplog is missing", + "userLogin": "justinr1234", + "contributors": [ + "justinr1234", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "14948", + "title": "[NEW] Subscription enabled marketplace", + "userLogin": "d-gubert", + "contributors": [ + "graywolf336", + "d-gubert", + "web-flow", + "tassoevan" + ] + }, + { + "pr": "15025", + "title": "[NEW] Deprecate MongoDB version 3.2", + "userLogin": "rodrigok", + "milestone": "1.3.0", + "contributors": [ + "rodrigok" + ] + }, + { + "pr": "14622", + "title": "[FIX] Russian grammatical errors", + "userLogin": "BehindLoader", + "contributors": [ + "BehindLoader" + ] + }, + { + "pr": "14412", + "title": "[FIX] Message attachments not allowing float numbers", + "userLogin": "MarcosSpessatto", + "milestone": "1.3.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "14515", + "title": "Wrong text when reporting a message", + "userLogin": "zdumitru", + "contributors": [ + "zdumitru", + "web-flow" + ] + }, + { + "pr": "14833", + "title": "[FIX] Typo in german translation", + "userLogin": "Le-onardo", + "contributors": [ + null, + "Le-onardo" + ] + }, + { + "pr": "15019", + "title": "[NEW] Options to filter discussion and livechat on Admin > Rooms", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "14916", + "title": "[FIX] users.setStatus REST endpoint not allowing reset status message", + "userLogin": "cardoso", + "contributors": [ + "cardoso" + ] + }, + { + "pr": "15013", + "title": "Add missing French translation", + "userLogin": "commiaI", + "contributors": [ + "commiaI", + "web-flow" + ] + }, + { + "pr": "15014", + "title": "[NEW] Settings to further customize GitLab OAuth", + "userLogin": "Hudell", + "milestone": "1.3.0", + "contributors": [ + "Hudell", + "web-flow" + ] + }, + { + "pr": "14935", + "title": "[NEW] Accept multiple redirect URIs on OAuth Apps", + "userLogin": "Hudell", + "milestone": "1.3.0", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "14675", + "title": "[NEW] Setting to configure custom authn context on SAML requests", + "userLogin": "Hudell", + "milestone": "1.3.0", + "contributors": [ + "Hudell", + "web-flow" + ] + }, + { + "pr": "15026", + "title": "Fix statistics error for apps on first load", + "userLogin": "d-gubert", + "milestone": "1.3.0", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "15006", + "title": "[FIX] SVG uploads crashing process", + "userLogin": "snoopotic", + "contributors": [ + "snoopotic", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "14941", + "title": "Always convert the sha256 password to lowercase on checking", + "userLogin": "MarcosSpessatto", + "milestone": "1.3.0", + "contributors": [ + "MarcosSpessatto", + "sampaiodiego" + ] + }, + { + "pr": "15022", + "title": "[IMPROVE] Connectivity Services License Sync", + "userLogin": "geekgonecrazy", + "contributors": [ + "geekgonecrazy", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "15021", + "title": "[FIX] Edit message with arrow up key if not last message", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "14845", + "title": "[FIX] Livechat dashboard average and reaction time labels", + "userLogin": "anandpathak", + "milestone": "1.3.0", + "contributors": [ + "anandpathak" + ] + }, + { + "pr": "14878", + "title": "New: Apps and integrations statistics", + "userLogin": "MarcosSpessatto", + "milestone": "1.3.0", + "contributors": [ + "MarcosSpessatto", + "d-gubert", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "14950", + "title": "[FIX] Edit permissions screen", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo", + "MarcosSpessatto", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "15020", + "title": "[FIX] Invite users auto complete cropping results", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "14879", + "title": "[NEW] Webdav File Picker", + "userLogin": "ubarsaiyan", + "milestone": "1.3.0", + "contributors": [ + "ubarsaiyan", + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14884", + "title": "improve: relocate some of wizard info to register", + "userLogin": "geekgonecrazy", + "milestone": "1.3.0", + "contributors": [ + "geekgonecrazy", + "rodrigok", + "web-flow" + ] + }, + { + "pr": "14861", + "title": "[FIX] Always displaying jumbomojis when using \"marked\" markdown", + "userLogin": "brakhane", + "milestone": "1.3.0", + "contributors": [ + "brakhane", + "tassoevan", + "web-flow" + ] + }, + { + "pr": "15004", + "title": "[IMPROVE] Add flag to identify remote federation users", + "userLogin": "alansikora", + "contributors": [ + "alansikora", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "15000", + "title": "[FIX] CustomOauth Identity Step errors displayed in HTML format", + "userLogin": "Hudell", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "15001", + "title": "[FIX] Custom User Status throttled by rate limiter", + "userLogin": "Hudell", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "14468", + "title": "[FIX] Not being able to mention users with \"all\" and \"here\" usernames - do not allow users register that usernames", + "userLogin": "hamidrezabstn", + "contributors": [ + "hamidrezabstn", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "14457", + "title": "Improve Docker compose readability", + "userLogin": "NateScarlet", + "contributors": [ + "NateScarlet", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "14992", + "title": "[IMPROVE] Extract federation config to its own file", + "userLogin": "d-gubert", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "14969", + "title": "Bump marked from 0.5.2 to 0.6.1", + "userLogin": "dependabot[bot]", + "contributors": [ + "dependabot[bot]", + "web-flow", + "engelgabriel", + "sampaiodiego" + ] + }, + { + "pr": "14971", + "title": "Remove unused Meteor dependency (yasinuslu:blaze-meta)", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "14977", + "title": "Bump photoswipe version to 4.1.3", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14976", + "title": "Bump node-rsa version to 1.0.5", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "14974", + "title": "Bump juice version to 5.2.0", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14973", + "title": "Remove unused dependency (lokijs)", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan", + "engelgabriel", + "web-flow" + ] + }, + { + "pr": "14966", + "title": "[FIX] Users staying online after logout", + "userLogin": "MarcosSpessatto", + "milestone": "1.3.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "14980", + "title": "Regression: patch to improve emoji render", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14722", + "title": "[IMPROVEMENT] patch to improve emoji render", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14965", + "title": "[FIX] Chrome doesn't load additional search results when bottom is reached", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14960", + "title": "[FIX] Wrong label order on room settings", + "userLogin": "Hudell", + "contributors": [ + "Hudell" + ] + }, + { + "pr": "14970", + "title": "[FIX] Allow storing the navigation history of unregistered Livechat visitors", + "userLogin": "renatobecker", + "milestone": "1.3.0", + "contributors": [ + "renatobecker" + ] + }, + { + "pr": "14922", + "title": "Bump jquery from 3.3.1 to 3.4.0 in /packages/rocketchat-livechat/.app", + "userLogin": "dependabot[bot]", + "contributors": [ + "dependabot[bot]", + "web-flow" + ] + }, + { + "pr": "14951", + "title": "[FIX] 50 custom emoji limit", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14952", + "title": "[FIX] eternal loading file list", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14967", + "title": "[FIX] load more messages", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14968", + "title": "[FIX] Loading indicator positioning", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14964", + "title": "[IMPROVE] Update tabs markup", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14949", + "title": "[FIX] Jump to message missing in Starred Messages", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "14963", + "title": "[IMPROVE] Remove too specific helpers isFirefox() and isChrome()", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "14753", + "title": "[FIX] Method `getUsersOfRoom` not returning offline users if limit is not defined", + "userLogin": "MarcosSpessatto", + "milestone": "1.3.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "14953", + "title": "[FIX] OTR key icon missing on messages", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "14945", + "title": "[FIX] Prevent error on trying insert message with duplicated id", + "userLogin": "MarcosSpessatto", + "milestone": "1.3.0", + "contributors": [ + "MarcosSpessatto", + "web-flow" + ] + }, + { + "pr": "14808", + "title": "[FIX] LDAP login with customField sync", + "userLogin": "magicbelette", + "milestone": "1.3.0", + "contributors": [ + "magicbelette", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "14958", + "title": "[FIX]Wrong custom status displayed on room leader panel", + "userLogin": "Hudell", + "contributors": [ + "Hudell", + "sampaiodiego", + "web-flow" + ] + }, + { + "pr": "14921", + "title": "[NEW] Setting to prevent Livechat agents online when Office Hours are closed", + "userLogin": "renatobecker", + "milestone": "1.3.0", + "contributors": [ + "renatobecker", + "web-flow" + ] + }, + { + "pr": "14915", + "title": "Callbacks perf", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow", + "sampaiodiego", + "tassoevan" + ] + }, + { + "pr": "14917", + "title": "Split oplog emitters in files", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "14909", + "title": "Extract canSendMessage function", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo", + "web-flow" + ] + }, + { + "pr": "14671", + "title": "[FIX] Video recorder message echo", + "userLogin": "vova-zush", + "milestone": "1.2.0", + "contributors": [ + "vova-zush" + ] + }, + { + "pr": "14785", + "title": "[FIX] Opening Livechat messages on mobile apps", + "userLogin": "zolbayars", + "milestone": "1.3.0", + "contributors": [ + "zolbayars", + "web-flow" + ] + }, + { + "pr": "14852", + "title": "[IMPROVE] Add descriptions on user data download buttons and popup info", + "userLogin": "MarcosSpessatto", + "milestone": "1.3.0", + "contributors": [ + "MarcosSpessatto" + ] + }, + { + "pr": "14880", + "title": "[FIX] SAML login by giving displayName priority over userName for fullName", + "userLogin": "pkolmann", + "milestone": "1.3.0", + "contributors": [ + "pkolmann" + ] + }, + { + "pr": "14851", + "title": "Improve: Get public key for marketplace", + "userLogin": "geekgonecrazy", + "milestone": "1.3.0", + "contributors": [ + "geekgonecrazy", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "14894", + "title": "[FIX] Not showing local app on App Details", + "userLogin": "d-gubert", + "milestone": "1.2.1", + "contributors": [ + "d-gubert" + ] + }, + { + "pr": "14889", + "title": "Merge master into develop & Set version to 1.3.0-develop", + "userLogin": "sampaiodiego", + "contributors": [ + "ggazzo", + "sampaiodiego", + "alansikora", + "Hudell", + "MarcosSpessatto", + "web-flow" + ] + } + ] + }, "1.2.1": { "node_version": "8.11.4", "npm_version": "6.4.1", @@ -32515,6 +33174,282 @@ "4.0" ], "pull_requests": [ + { + "pr": "14898", + "title": "Release 1.2.1", + "userLogin": "sampaiodiego", + "contributors": [ + "d-gubert", + "sampaiodiego" + ] + }, + { + "pr": "14894", + "title": "[FIX] Not showing local app on App Details", + "userLogin": "d-gubert", + "milestone": "1.2.1", + "contributors": [ + "d-gubert" + ] + } + ] + }, + "1.3.0-rc.1": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "15041", + "title": "Regression: fix code style, setup wizard error and profile page header", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "15039", + "title": "Regression: Framework version being attached to a request that doesn't require it", + "userLogin": "graywolf336", + "contributors": [ + "graywolf336" + ] + } + ] + }, + "1.3.0-rc.2": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "15046", + "title": "Update Livechat widget", + "userLogin": "renatobecker", + "milestone": "1.3.0", + "contributors": [ + "renatobecker" + ] + } + ] + }, + "1.0.4": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "15054", + "title": "[FIX] Not sanitized message types", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + } + ] + }, + "1.1.4": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "15054", + "title": "[FIX] Not sanitized message types", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + } + ] + }, + "1.2.2": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "15054", + "title": "[FIX] Not sanitized message types", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + } + ] + }, + "1.2.3": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [] + }, + "1.3.0-rc.3": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "15067", + "title": "Regression: getSetupWizardParameters", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "15060", + "title": "[FIX] setupWizard calling multiple getSetupWizardParameters", + "userLogin": "ggazzo", + "milestone": "1.3.0", + "contributors": [ + "ggazzo" + ] + }, + { + "pr": "15027", + "title": "Regression: Webdav File Picker search and fixed overflows", + "userLogin": "ubarsaiyan", + "milestone": "1.3.0", + "contributors": [ + "ubarsaiyan", + "ggazzo" + ] + }, + { + "pr": "15054", + "title": "[FIX] Not sanitized message types", + "userLogin": "ggazzo", + "contributors": [ + "ggazzo" + ] + } + ] + }, + "1.3.0-rc.4": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "15080", + "title": "Regression: Improve apps bridges for HA setup", + "userLogin": "d-gubert", + "milestone": "1.3.0", + "contributors": [ + "d-gubert", + "web-flow", + "sampaiodiego" + ] + }, + { + "pr": "15075", + "title": "Regression: displaying errors for apps not installed from Marketplace", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan", + "ggazzo", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "15076", + "title": "Regression: Marketplace app pricing plan description", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan", + "d-gubert", + "web-flow" + ] + }, + { + "pr": "15077", + "title": "Regression: uninstall subscribed app modal", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan" + ] + }, + { + "pr": "15045", + "title": "Regression: Apps and Marketplace UI issues", + "userLogin": "tassoevan", + "milestone": "1.3.0", + "contributors": [ + "tassoevan", + "rodrigok", + "geekgonecrazy", + "graywolf336", + "d-gubert" + ] + } + ] + }, + "1.3.0": { + "node_version": "8.11.4", + "npm_version": "6.4.1", + "mongo_versions": [ + "3.2", + "3.4", + "3.6", + "4.0" + ], + "pull_requests": [ + { + "pr": "14898", + "title": "Release 1.2.1", + "userLogin": "sampaiodiego", + "contributors": [ + "d-gubert", + "sampaiodiego" + ] + }, { "pr": "14894", "title": "[FIX] Not showing local app on App Details", diff --git a/.meteor/packages b/.meteor/packages index 6cfd59d2a843..98e3d061be48 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -71,7 +71,6 @@ raix:handlebar-helpers rocketchat:push raix:ui-dropped-event todda00:friendly-slugs -yasinuslu:blaze-meta tap:i18n underscore@1.0.10 diff --git a/.meteor/versions b/.meteor/versions index e1e6cb375828..babbbf8f8b29 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -70,7 +70,7 @@ konecty:change-case@2.3.0 konecty:delayed-task@1.0.0 konecty:mongo-counter@0.0.5_3 konecty:multiple-instances-status@1.1.0 -konecty:user-presence@2.4.0 +konecty:user-presence@2.5.0 launch-screen@1.1.1 less@2.8.0 littledata:synced-cron@1.5.1 @@ -129,7 +129,7 @@ rocketchat:i18n@0.0.1 rocketchat:livechat@0.0.1 rocketchat:mongo-config@0.0.1 rocketchat:monitoring@2.30.2_3 -rocketchat:oauth2-server@2.0.0 +rocketchat:oauth2-server@2.1.0 rocketchat:push@3.3.1 rocketchat:streamer@1.0.2 rocketchat:version@1.0.0 @@ -159,4 +159,3 @@ underscore@1.0.10 url@1.2.0 webapp@1.7.2 webapp-hashing@1.0.9 -yasinuslu:blaze-meta@0.3.3 diff --git a/.stylelintignore b/.stylelintignore index 4f8093de49f0..c62f04a77570 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,3 +1,4 @@ app/theme/client/vendor/fontello/css/fontello.css packages/meteor-autocomplete/client/autocomplete.css app/katex/katex.min.css +app/emoji-emojione/client/*.css diff --git a/.travis/snap.sh b/.travis/snap.sh index 1ae2d0f9e0e6..6b224c66c226 100755 --- a/.travis/snap.sh +++ b/.travis/snap.sh @@ -17,7 +17,7 @@ elif [[ $TRAVIS_TAG ]]; then RC_VERSION=$TRAVIS_TAG else CHANNEL=edge - RC_VERSION=1.2.1 + RC_VERSION=1.3.0 fi echo "Preparing to trigger a snap release for $CHANNEL channel" diff --git a/HISTORY.md b/HISTORY.md index 853825105974..c565383bd7b1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,149 @@ +# 1.3.0 +`2019-08-02 · 9 🎉 · 6 🚀 · 31 🐛 · 31 🔍 · 29 👩💻👨💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🎉 New features + +- Show helpful error when oplog is missing ([#14954](https://github.com/RocketChat/Rocket.Chat/pull/14954) by [@justinr1234](https://github.com/justinr1234)) +- Subscription enabled marketplace ([#14948](https://github.com/RocketChat/Rocket.Chat/pull/14948)) +- Deprecate MongoDB version 3.2 ([#15025](https://github.com/RocketChat/Rocket.Chat/pull/15025)) +- Options to filter discussion and livechat on Admin > Rooms ([#15019](https://github.com/RocketChat/Rocket.Chat/pull/15019)) +- Settings to further customize GitLab OAuth ([#15014](https://github.com/RocketChat/Rocket.Chat/pull/15014)) +- Accept multiple redirect URIs on OAuth Apps ([#14935](https://github.com/RocketChat/Rocket.Chat/pull/14935)) +- Setting to configure custom authn context on SAML requests ([#14675](https://github.com/RocketChat/Rocket.Chat/pull/14675)) +- Webdav File Picker ([#14879](https://github.com/RocketChat/Rocket.Chat/pull/14879) by [@ubarsaiyan](https://github.com/ubarsaiyan)) +- Setting to prevent Livechat agents online when Office Hours are closed ([#14921](https://github.com/RocketChat/Rocket.Chat/pull/14921)) + +### 🚀 Improvements + +- Connectivity Services License Sync ([#15022](https://github.com/RocketChat/Rocket.Chat/pull/15022)) +- Add flag to identify remote federation users ([#15004](https://github.com/RocketChat/Rocket.Chat/pull/15004)) +- Extract federation config to its own file ([#14992](https://github.com/RocketChat/Rocket.Chat/pull/14992)) +- Update tabs markup ([#14964](https://github.com/RocketChat/Rocket.Chat/pull/14964)) +- Remove too specific helpers isFirefox() and isChrome() ([#14963](https://github.com/RocketChat/Rocket.Chat/pull/14963)) +- Add descriptions on user data download buttons and popup info ([#14852](https://github.com/RocketChat/Rocket.Chat/pull/14852)) + +### 🐛 Bug fixes + +- Russian grammatical errors ([#14622](https://github.com/RocketChat/Rocket.Chat/pull/14622) by [@BehindLoader](https://github.com/BehindLoader)) +- Message attachments not allowing float numbers ([#14412](https://github.com/RocketChat/Rocket.Chat/pull/14412)) +- Typo in german translation ([#14833](https://github.com/RocketChat/Rocket.Chat/pull/14833) by [@Le-onardo](https://github.com/Le-onardo)) +- users.setStatus REST endpoint not allowing reset status message ([#14916](https://github.com/RocketChat/Rocket.Chat/pull/14916)) +- SVG uploads crashing process ([#15006](https://github.com/RocketChat/Rocket.Chat/pull/15006) by [@snoopotic](https://github.com/snoopotic)) +- Edit message with arrow up key if not last message ([#15021](https://github.com/RocketChat/Rocket.Chat/pull/15021)) +- Livechat dashboard average and reaction time labels ([#14845](https://github.com/RocketChat/Rocket.Chat/pull/14845) by [@anandpathak](https://github.com/anandpathak)) +- Edit permissions screen ([#14950](https://github.com/RocketChat/Rocket.Chat/pull/14950)) +- Invite users auto complete cropping results ([#15020](https://github.com/RocketChat/Rocket.Chat/pull/15020)) +- Always displaying jumbomojis when using "marked" markdown ([#14861](https://github.com/RocketChat/Rocket.Chat/pull/14861)) +- CustomOauth Identity Step errors displayed in HTML format ([#15000](https://github.com/RocketChat/Rocket.Chat/pull/15000)) +- Custom User Status throttled by rate limiter ([#15001](https://github.com/RocketChat/Rocket.Chat/pull/15001)) +- Not being able to mention users with "all" and "here" usernames - do not allow users register that usernames ([#14468](https://github.com/RocketChat/Rocket.Chat/pull/14468) by [@hamidrezabstn](https://github.com/hamidrezabstn)) +- Users staying online after logout ([#14966](https://github.com/RocketChat/Rocket.Chat/pull/14966)) +- Chrome doesn't load additional search results when bottom is reached ([#14965](https://github.com/RocketChat/Rocket.Chat/pull/14965)) +- Wrong label order on room settings ([#14960](https://github.com/RocketChat/Rocket.Chat/pull/14960)) +- Allow storing the navigation history of unregistered Livechat visitors ([#14970](https://github.com/RocketChat/Rocket.Chat/pull/14970)) +- 50 custom emoji limit ([#14951](https://github.com/RocketChat/Rocket.Chat/pull/14951)) +- eternal loading file list ([#14952](https://github.com/RocketChat/Rocket.Chat/pull/14952)) +- load more messages ([#14967](https://github.com/RocketChat/Rocket.Chat/pull/14967)) +- Loading indicator positioning ([#14968](https://github.com/RocketChat/Rocket.Chat/pull/14968)) +- Jump to message missing in Starred Messages ([#14949](https://github.com/RocketChat/Rocket.Chat/pull/14949)) +- Method `getUsersOfRoom` not returning offline users if limit is not defined ([#14753](https://github.com/RocketChat/Rocket.Chat/pull/14753)) +- OTR key icon missing on messages ([#14953](https://github.com/RocketChat/Rocket.Chat/pull/14953)) +- Prevent error on trying insert message with duplicated id ([#14945](https://github.com/RocketChat/Rocket.Chat/pull/14945)) +- LDAP login with customField sync ([#14808](https://github.com/RocketChat/Rocket.Chat/pull/14808) by [@magicbelette](https://github.com/magicbelette)) +- Wrong custom status displayed on room leader panel ([#14958](https://github.com/RocketChat/Rocket.Chat/pull/14958)) +- Video recorder message echo ([#14671](https://github.com/RocketChat/Rocket.Chat/pull/14671) by [@vova-zush](https://github.com/vova-zush)) +- Opening Livechat messages on mobile apps ([#14785](https://github.com/RocketChat/Rocket.Chat/pull/14785) by [@zolbayars](https://github.com/zolbayars)) +- SAML login by giving displayName priority over userName for fullName ([#14880](https://github.com/RocketChat/Rocket.Chat/pull/14880) by [@pkolmann](https://github.com/pkolmann)) +- setupWizard calling multiple getSetupWizardParameters ([#15060](https://github.com/RocketChat/Rocket.Chat/pull/15060)) + + +🔍 Minor changes + +- Wrong text when reporting a message ([#14515](https://github.com/RocketChat/Rocket.Chat/pull/14515) by [@zdumitru](https://github.com/zdumitru)) +- Add missing French translation ([#15013](https://github.com/RocketChat/Rocket.Chat/pull/15013) by [@commiaI](https://github.com/commiaI)) +- Fix statistics error for apps on first load ([#15026](https://github.com/RocketChat/Rocket.Chat/pull/15026)) +- Always convert the sha256 password to lowercase on checking ([#14941](https://github.com/RocketChat/Rocket.Chat/pull/14941)) +- New: Apps and integrations statistics ([#14878](https://github.com/RocketChat/Rocket.Chat/pull/14878)) +- improve: relocate some of wizard info to register ([#14884](https://github.com/RocketChat/Rocket.Chat/pull/14884)) +- Improve Docker compose readability ([#14457](https://github.com/RocketChat/Rocket.Chat/pull/14457) by [@NateScarlet](https://github.com/NateScarlet)) +- Bump marked from 0.5.2 to 0.6.1 ([#14969](https://github.com/RocketChat/Rocket.Chat/pull/14969) by [@dependabot[bot]](https://github.com/dependabot[bot])) +- Remove unused Meteor dependency (yasinuslu:blaze-meta) ([#14971](https://github.com/RocketChat/Rocket.Chat/pull/14971)) +- Bump photoswipe version to 4.1.3 ([#14977](https://github.com/RocketChat/Rocket.Chat/pull/14977)) +- Bump node-rsa version to 1.0.5 ([#14976](https://github.com/RocketChat/Rocket.Chat/pull/14976)) +- Bump juice version to 5.2.0 ([#14974](https://github.com/RocketChat/Rocket.Chat/pull/14974)) +- Remove unused dependency (lokijs) ([#14973](https://github.com/RocketChat/Rocket.Chat/pull/14973)) +- Regression: patch to improve emoji render ([#14980](https://github.com/RocketChat/Rocket.Chat/pull/14980)) +- [IMPROVEMENT] patch to improve emoji render ([#14722](https://github.com/RocketChat/Rocket.Chat/pull/14722)) +- Bump jquery from 3.3.1 to 3.4.0 in /packages/rocketchat-livechat/.app ([#14922](https://github.com/RocketChat/Rocket.Chat/pull/14922) by [@dependabot[bot]](https://github.com/dependabot[bot])) +- Callbacks perf ([#14915](https://github.com/RocketChat/Rocket.Chat/pull/14915)) +- Split oplog emitters in files ([#14917](https://github.com/RocketChat/Rocket.Chat/pull/14917)) +- Extract canSendMessage function ([#14909](https://github.com/RocketChat/Rocket.Chat/pull/14909)) +- Improve: Get public key for marketplace ([#14851](https://github.com/RocketChat/Rocket.Chat/pull/14851)) +- Merge master into develop & Set version to 1.3.0-develop ([#14889](https://github.com/RocketChat/Rocket.Chat/pull/14889)) +- Regression: fix code style, setup wizard error and profile page header ([#15041](https://github.com/RocketChat/Rocket.Chat/pull/15041)) +- Regression: Framework version being attached to a request that doesn't require it ([#15039](https://github.com/RocketChat/Rocket.Chat/pull/15039)) +- Update Livechat widget ([#15046](https://github.com/RocketChat/Rocket.Chat/pull/15046)) +- Regression: getSetupWizardParameters ([#15067](https://github.com/RocketChat/Rocket.Chat/pull/15067)) +- Regression: Webdav File Picker search and fixed overflows ([#15027](https://github.com/RocketChat/Rocket.Chat/pull/15027) by [@ubarsaiyan](https://github.com/ubarsaiyan)) +- Regression: Improve apps bridges for HA setup ([#15080](https://github.com/RocketChat/Rocket.Chat/pull/15080)) +- Regression: displaying errors for apps not installed from Marketplace ([#15075](https://github.com/RocketChat/Rocket.Chat/pull/15075)) +- Regression: Marketplace app pricing plan description ([#15076](https://github.com/RocketChat/Rocket.Chat/pull/15076)) +- Regression: uninstall subscribed app modal ([#15077](https://github.com/RocketChat/Rocket.Chat/pull/15077)) +- Regression: Apps and Marketplace UI issues ([#15045](https://github.com/RocketChat/Rocket.Chat/pull/15045)) + + + +### 👩💻👨💻 Contributors 😍 + +- [@BehindLoader](https://github.com/BehindLoader) +- [@Le-onardo](https://github.com/Le-onardo) +- [@NateScarlet](https://github.com/NateScarlet) +- [@anandpathak](https://github.com/anandpathak) +- [@commiaI](https://github.com/commiaI) +- [@dependabot[bot]](https://github.com/dependabot[bot]) +- [@hamidrezabstn](https://github.com/hamidrezabstn) +- [@justinr1234](https://github.com/justinr1234) +- [@magicbelette](https://github.com/magicbelette) +- [@pkolmann](https://github.com/pkolmann) +- [@snoopotic](https://github.com/snoopotic) +- [@ubarsaiyan](https://github.com/ubarsaiyan) +- [@vova-zush](https://github.com/vova-zush) +- [@zdumitru](https://github.com/zdumitru) +- [@zolbayars](https://github.com/zolbayars) + +### 👩💻👨💻 Core Team 🤓 + +- [@Hudell](https://github.com/Hudell) +- [@MarcosSpessatto](https://github.com/MarcosSpessatto) +- [@alansikora](https://github.com/alansikora) +- [@brakhane](https://github.com/brakhane) +- [@cardoso](https://github.com/cardoso) +- [@d-gubert](https://github.com/d-gubert) +- [@engelgabriel](https://github.com/engelgabriel) +- [@geekgonecrazy](https://github.com/geekgonecrazy) +- [@ggazzo](https://github.com/ggazzo) +- [@graywolf336](https://github.com/graywolf336) +- [@renatobecker](https://github.com/renatobecker) +- [@rodrigok](https://github.com/rodrigok) +- [@sampaiodiego](https://github.com/sampaiodiego) +- [@tassoevan](https://github.com/tassoevan) + +# 1.2.2 +`2019-07-29` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + # 1.2.1 -`2019-06-28 · 1 🐛 · 1 👩💻👨💻` +`2019-06-28 · 1 🐛 · 1 🔍 · 2 👩💻👨💻` ### Engine versions - Node: `8.11.4` @@ -11,9 +154,17 @@ - Not showing local app on App Details ([#14894](https://github.com/RocketChat/Rocket.Chat/pull/14894)) + +🔍 Minor changes + +- Release 1.2.1 ([#14898](https://github.com/RocketChat/Rocket.Chat/pull/14898)) + + + ### 👩💻👨💻 Core Team 🤓 - [@d-gubert](https://github.com/d-gubert) +- [@sampaiodiego](https://github.com/sampaiodiego) # 1.2.0 `2019-06-27 · 8 🎉 · 4 🚀 · 12 🐛 · 8 🔍 · 21 👩💻👨💻` @@ -97,6 +248,14 @@ - [@sampaiodiego](https://github.com/sampaiodiego) - [@tassoevan](https://github.com/tassoevan) +# 1.1.4 +`2019-07-29` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + # 1.1.3 `2019-06-21 · 1 🐛 · 2 🔍 · 2 👩💻👨💻` @@ -334,6 +493,22 @@ - [@sampaiodiego](https://github.com/sampaiodiego) - [@tassoevan](https://github.com/tassoevan) +# 1.0.4 +`2019-07-29 · 1 🐛 · 1 👩💻👨💻` + +### Engine versions +- Node: `8.11.4` +- NPM: `6.4.1` +- MongoDB: `3.2, 3.4, 3.6, 4.0` + +### 🐛 Bug fixes + +- Not sanitized message types ([#15054](https://github.com/RocketChat/Rocket.Chat/pull/15054)) + +### 👩💻👨💻 Core Team 🤓 + +- [@ggazzo](https://github.com/ggazzo) + # 1.0.3 `2019-05-09 · 1 🔍 · 8 👩💻👨💻` diff --git a/app/2fa/server/loginHandler.js b/app/2fa/server/loginHandler.js index 3afc20b6cf21..a70f582f946b 100644 --- a/app/2fa/server/loginHandler.js +++ b/app/2fa/server/loginHandler.js @@ -36,4 +36,4 @@ callbacks.add('onValidateLogin', (login) => { throw new Meteor.Error('totp-invalid', 'TOTP Invalid'); } } -}); +}, callbacks.priority.MEDIUM, '2fa'); diff --git a/app/api/server/api.js b/app/api/server/api.js index 98688b1c584a..9e42911baed4 100644 --- a/app/api/server/api.js +++ b/app/api/server/api.js @@ -120,6 +120,16 @@ class APIClass extends Restivus { }; } + internalError(msg) { + return { + statusCode: 500, + body: { + success: false, + error: msg || 'Internal error occured', + }, + }; + } + unauthorized(msg) { return { statusCode: 403, diff --git a/app/api/server/v1/channels.js b/app/api/server/v1/channels.js index f92132a7d4e5..1a4e74afbbcb 100644 --- a/app/api/server/v1/channels.js +++ b/app/api/server/v1/channels.js @@ -23,7 +23,7 @@ function findChannelByIdOrName({ params, checkedArchived = true, userId }) { room = Rooms.findOneByName(params.roomName, { fields }); } - if (!room || room.t !== 'c') { + if (!room || (room.t !== 'c' && room.t !== 'l')) { throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any channel'); } diff --git a/app/api/server/v1/roles.js b/app/api/server/v1/roles.js index 67a992427a61..22a0d543ce34 100644 --- a/app/api/server/v1/roles.js +++ b/app/api/server/v1/roles.js @@ -3,6 +3,7 @@ import { Match, check } from 'meteor/check'; import { Roles } from '../../../models'; import { API } from '../api'; +import { getUsersInRole, hasPermission } from '../../../authorization/server'; API.v1.addRoute('roles.list', { authRequired: true }, { get() { @@ -55,3 +56,33 @@ API.v1.addRoute('roles.addUserToRole', { authRequired: true }, { }); }, }); + +API.v1.addRoute('roles.getUsersInRole', { authRequired: true }, { + get() { + const { roomId, role } = this.queryParams; + const { offset, count = 50 } = this.getPaginationItems(); + + const fields = { + name: 1, + username: 1, + emails: 1, + }; + + if (!role) { + throw new Meteor.Error('error-param-not-provided', 'Query param "role" is required'); + } + if (!hasPermission(this.userId, 'access-permissions')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + if (roomId && !hasPermission(this.userId, 'view-other-user-channels')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + const users = getUsersInRole(role, roomId, { + limit: count, + sort: { username: 1 }, + skip: offset, + fields, + }).fetch(); + return API.v1.success({ users }); + }, +}); diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 8512a5032526..317b6e8a6bf8 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -369,7 +369,7 @@ API.v1.addRoute('users.setStatus', { authRequired: true }, { } Meteor.runAsUser(user._id, () => { - if (this.bodyParams.message) { + if (this.bodyParams.message || this.bodyParams.message.length === 0) { setStatusText(user._id, this.bodyParams.message); } if (this.bodyParams.status) { diff --git a/app/apps/assets/stylesheets/apps.css b/app/apps/assets/stylesheets/apps.css index 0037d1191c73..438b99d813ee 100644 --- a/app/apps/assets/stylesheets/apps.css +++ b/app/apps/assets/stylesheets/apps.css @@ -1,3 +1,4 @@ +.rc-apps-section, .rc-apps-marketplace { display: flex; @@ -24,7 +25,7 @@ letter-spacing: 0; text-transform: initial; - color: #54585e; + color: var(--color-dark-medium); font-size: 22px; font-weight: normal; @@ -59,94 +60,6 @@ line-height: 20px; } - .rc-apps-details { - margin-bottom: 0; - padding: 0; - - &__description { - padding-bottom: 50px; - - border-bottom: 1.5px solid #efefef; - } - - &__photo { - width: 96px; - height: 96px; - margin-right: 21px; - - background-color: #f7f7f7; - } - - &__content { - padding: 0; - } - - &__col { - display: inline-block; - - margin-right: 8px; - } - - &__bundles { - display: flex; - - padding-bottom: 20px; - - border-bottom: 1.5px solid #efefef; - } - - &__bundle { - display: flex; - - width: 50%; - } - - &__bundle_icons { - display: flex; - overflow: hidden; - - min-width: 99px; - max-width: 99px; - height: 99px; - - padding: 2px; - - border-radius: 2px; - - background-color: #e6e8eb; - flex-wrap: wrap; - } - - &__bundle_icon { - min-width: 40px; - max-width: 40px; - height: 40px; - - margin-top: 5px; - margin-left: 5px; - - border-radius: 2px; - - background-color: #f7f7f7; - background-repeat: no-repeat; - background-position: center center; - background-size: contain; - } - - &__bundle_body { - padding: 5px 10px; - - color: #9da1a7; - - &_title { - color: #151924; - - font-size: 1.1em; - font-weight: 500; - } - } - } - .rc-apps-container { margin-top: 0; padding-bottom: 15px; @@ -155,7 +68,7 @@ .rc-apps-container__header { padding-top: 10px; - border-bottom: 1.5px solid #efefef; + border-bottom: 1.5px solid var(--color-gray-lightest); } /* @@ -189,7 +102,7 @@ color: #9da2a9; border-radius: 2px; - background: #f3f4f5; + background: var(--color-gray-lightest); font-size: 12px; font-weight: 500; @@ -239,22 +152,6 @@ padding-right: 10px; } - td.rc-apps-marketplace-price { - text-align: right; - - button { - font-weight: 600; - } - - .rc-icon { - color: #3582f3; - } - } - - th.rc-apps-marketplace-price { - width: 120px; - } - &__wrap-actions { & > .loading { display: none; @@ -300,6 +197,12 @@ height: 100vh; + margin-top: 20px; + + & .rc-form-filters { + margin: 8px 0; + } + & .js-sort { cursor: pointer; @@ -321,6 +224,57 @@ font-size: 1rem; } } + + & tbody .rc-table-tr .rc-apps-section__app-menu-trigger { + visibility: hidden; + } + + & tbody .rc-table-tr:hover .rc-apps-section__app-menu-trigger { + visibility: visible; + } + + & tbody .rc-table-tr:not(.table-no-click):not(.table-no-pointer):hover { + background-color: #f7f8fa; + } + + & .rc-table-info { + margin: 0; + justify-content: center; + + & .rc-table-title, + & .rc-table-subtitle { + font-size: 0.875rem; + line-height: 1.25rem; + } + + & .rc-apps-categories { + display: flex; + + height: 1.25rem; + margin: 0 -0.25rem; + align-items: center; + flex-wrap: wrap; + + & .rc-apps-category { + overflow: hidden; + flex: 0 0 auto; + + box-sizing: border-box; + margin: 0.125rem 0.25rem; + padding: 0.0625rem 0.25rem; + + text-transform: none; + text-overflow: ellipsis; + + color: var(--color-gray); + background-color: var(--color-gray-lightest); + + font-size: 0.625rem; + font-weight: 500; + line-height: 0.875rem; + } + } + } } @media (width <= 700px) { @@ -331,6 +285,89 @@ } } } + + &__app-menu-trigger { + + position: relative; + + display: flex; + flex: 0 0 auto; + + margin-left: auto; + padding: 0; + + font-size: 0.875rem; + line-height: 1.25rem; + align-items: center; + appearance: none; + margin-inline-start: auto; + + &:active { + transform: translateY(2px); + + opacity: 0.9; + } + + &:active::before { + top: -2px; + } + + &::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + content: ""; + cursor: pointer; + } + + & .rc-icon { + margin: 0; + } + } + + &__spinning-icon { + animation: spin 1s linear infinite; + } + + &__button--working { + opacity: 0.6; + } + + &__status { + width: 100%; + + color: var(--rc-color-primary-light); + + line-height: 40px; + + &--warning { + color: var(--rc-color-alert); + } + + &--failed { + color: var(--rc-color-error); + } + } + + &__status-column { + width: 150px; + } + + tr .rc-apps-section__table-button--hideable { + visibility: hidden; + } + + tr .rc-apps-section__table-button--working, + tr:hover .rc-apps-section__table-button--hideable { + visibility: visible; + } + + .rc-apps-section__table-button--working { + opacity: 0.6; + } } @keyframes play90 { diff --git a/app/apps/client/admin/appLogs.js b/app/apps/client/admin/appLogs.js index bcd621370cde..fc6500abfdd1 100644 --- a/app/apps/client/admin/appLogs.js +++ b/app/apps/client/admin/appLogs.js @@ -96,8 +96,8 @@ Template.appLogs.events({ $(e.currentTarget).find('.button-down').removeClass('arrow-up'); }, - 'click .js-cancel': (e, t) => { - FlowRouter.go('app-manage', { appId: t.app.get().id }, { version: FlowRouter.getQueryParam('version') }); + 'click .js-cancel': () => { + FlowRouter.go('apps'); }, 'click .js-refresh': (e, t) => { diff --git a/app/apps/client/admin/appManage.css b/app/apps/client/admin/appManage.css new file mode 100644 index 000000000000..caeba3c95d6c --- /dev/null +++ b/app/apps/client/admin/appManage.css @@ -0,0 +1,181 @@ +#rocket-chat .rc-apps-details { + margin-bottom: 0; + padding: 0; + + &__photo { + width: 96px; + height: 96px; + margin-right: 21px; + } + + &__content { + padding: 0; + + color: var(--color-gray); + justify-content: flex-start; + } + + &__description { + padding-bottom: 50px; + + border-bottom: 1.5px solid var(--color-gray-light); + } + + &__col { + display: inline-block; + + margin-right: 8px; + } + + &__bundles { + display: flex; + + padding-bottom: 20px; + + border-bottom: 1.5px solid var(--color-gray-light); + } + + &__bundle { + display: flex; + + width: 50%; + } + + &__bundle_icons { + display: flex; + overflow: hidden; + + min-width: 99px; + max-width: 99px; + height: 99px; + + padding: 2px; + + border-radius: 2px; + + background-color: var(--color-gray-light); + flex-wrap: wrap; + } + + &__bundle_icon { + min-width: 40px; + max-width: 40px; + height: 40px; + + margin-top: 5px; + margin-left: 5px; + + border-radius: 2px; + + background-color: #f7f7f7; + background-repeat: no-repeat; + background-position: center center; + background-size: contain; + } + + &__bundle_body { + padding: 5px 10px; + + color: var(--color-gray); + + &_title { + color: var(--color-darkest); + + font-size: 1.1em; + font-weight: 500; + } + } + + &__alert { + margin: 0.25rem 0; + padding: 0.5rem 1rem; + + border-radius: 4px; + + font-size: 0.875rem; + line-height: 1.25rem; + } + + &__alert-error { + color: var(--color-red); + background-color: #ffe9ec; + } + + &__alert-warning { + color: #b68d00; + background-color: #fff6d6; + } + + &__name { + flex: 0 0 1.75rem; + + margin: 0; + + text-transform: none; + + color: var(--color-dark-medium); + + font-family: inherit; + font-size: 1.375rem; + font-weight: normal; + line-height: 1.75rem; + } + + &__author { + font-family: inherit; + font-size: 14px; + font-weight: 500; + line-height: 20px; + } + + &__side-info { + display: inline-flex; + align-items: center; + + &::before { + display: inline-block; + + width: 1px; + height: 12px; + margin: 0 8px; + + content: ''; + + background-color: currentColor; + } + + &--twice::before { + margin: 0 16px; + } + } + + &__side-info-wrapper { + flex: 1; + } + + &__row--centered { + align-items: center; + } + + &__app-status { + display: flex; + flex: 1; + + margin-top: 8px; + align-items: center; + } + + & .rc-button.loading { + padding: 0 1.5rem; + + opacity: 0.6; + + &::before { + display: none; + } + + & > .rc-icon { + animation: spin 1s linear infinite; + } + } +} diff --git a/app/apps/client/admin/appManage.html b/app/apps/client/admin/appManage.html index b20244ccb06a..037f19cfca0e 100644 --- a/app/apps/client/admin/appManage.html +++ b/app/apps/client/admin/appManage.html @@ -1,27 +1,33 @@ - {{#with app}} - + {{# header sectionName='App_Details' fixedHeight=true hideHelp=true fullpage=true}} - - {{#unless disabled}} - {{_ "Cancel" }} - {{/unless}} - {{_ "Save_changes" }} - + {{#if installed}} + + {{#unless isSettingsPristine}} + {{_ "Cancel" }} + {{/unless}} + {{_ "Save_changes" }} + + {{/if}} - {{> icon icon="cross"}} + + {{> icon icon="cross"}} + {{/header}} + {{#requiresPermission 'manage-apps'}} - {{#if hasError}} + {{#if error}} - {{theError}} + {{error}} - {{else if isReady}} + {{else if isLoading}} + {{> loading}} + {{else}} {{#if iconFileData}} @@ -30,36 +36,55 @@ {{/if}} + {{name}} - {{name}} + {{#if author.name}} + by {{author.name}} + {{/if}} + Version {{version}} - {{#if author.name}} - - by {{author.name}} | Version {{version}} - - {{/if}} - - {{#if isInstalled}} - {{#if newVersion}} - {{> icon icon="circled-arrow-down"}} {{_ "Update_to_version" version=newVersion }} + + + {{#let buttonProps=(appButtonProps .) statusSpanProps=(appStatusSpanProps .)}} + {{#if buttonProps}} + + {{#if working}} + {{> icon icon="loading" block="rc-icon--default-size rc-apps-section__spinning-icon"}} + {{else if ($eq buttonProps.action 'update')}} + {{> icon icon="reload" block="rc-icon--default-size"}} + {{else}} + {{> icon icon="circled-arrow-down" block="rc-icon--default-size"}} + {{/if}} + {{_ buttonProps.label}} + {{/if}} - {{> icon icon="trash"}} {{_ "Delete" }} - {{#if isEnabled}} - {{> icon icon="ban"}} {{_ "Deactivate" }} - {{else}} - {{> icon icon="check"}} {{_ "Activate" }} + + {{#if statusSpanProps}} + + {{> icon icon=statusSpanProps.icon block="rc-icon--default-size"}} + {{_ statusSpanProps.label}} + {{/if}} - {{> icon icon="list-alt"}} {{_ "View_Logs" }} - {{else}} - {{#if hasPurchased}} - {{> icon icon="download"}} {{_ "Purchased"}} - {{else}} - {{#if $eq price 0}} - {{> icon icon="circled-arrow-down"}} {{_ "Free"}} - {{else}} - {{> icon icon="circled-arrow-down"}} {{displayPrice}} + {{/let}} + + + {{#unless installed}} + {{#if priceDisplay}} + + {{priceDisplay}} + {{/if}} - {{/if}} + {{/unless}} + + + {{#if installed}} + + {{> icon icon="menu" block="rc-icon--default-size"}} + {{/if}} @@ -67,6 +92,18 @@ {{name}} + {{#each warnings}} + + {{.}} + + {{/each}} + + {{#each errors}} + + {{.}} + + {{/each}} + {{#if categories}} {{_ "Categories"}} @@ -131,7 +168,9 @@ {{_ "Bundles"}} {{ bundleName }} - {{bundleAppNames apps}} + {{#if apps}} + {{bundleAppNames apps}} + {{/if}} {{/each}} @@ -180,14 +219,14 @@ {{_ "Settings"}} {{#if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{#if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -202,13 +241,13 @@ {{_ "Settings"}} {{_ i18nLabel}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -225,14 +264,14 @@ {{_ "Settings"}} {{# if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -249,14 +288,14 @@ {{_ "Settings"}} {{# if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -271,14 +310,14 @@ {{_ "Settings"}} {{# if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -295,14 +334,14 @@ {{_ "Settings"}} {{# if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -328,14 +367,14 @@ {{_ "Settings"}} {{# if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -354,14 +393,14 @@ {{_ "Settings"}} {{# if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -379,41 +418,17 @@ {{_ "Settings"}} {{# if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} - - {{/if}} - - {{ else if $eq type 'language'}} @@ -421,22 +436,22 @@ {{_ "Settings"}} {{_ i18nLabel}} - {{#each languages}} - {{_ name}} + {{#each language in languages}} + {{_ language.name}} {{/each}} {{> icon block="rc-select__arrow" icon="arrow-down" }} {{# if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{# if i18nAlert}} {{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -484,10 +499,10 @@ {{_ "Settings"}} {{/if}} {{#if i18nDescription}} - {{{parseDescription i18nDescription}}} + {{{RocketChatMarkdown (_ i18nDescription)}}} {{/if}} {{#if i18nAlert}} - {{{parseDescription i18nAlert}}} + {{{RocketChatMarkdown (_ i18nAlert)}}} {{/if}} @@ -499,8 +514,6 @@ {{_ "Settings"}} {{/if}} - {{else}} - {{> loading}} {{/if}} {{/requiresPermission}} diff --git a/app/apps/client/admin/appManage.js b/app/apps/client/admin/appManage.js index 1b4e8261f8b3..e94565cca3cb 100644 --- a/app/apps/client/admin/appManage.js +++ b/app/apps/client/admin/appManage.js @@ -1,331 +1,303 @@ import { Meteor } from 'meteor/meteor'; -import { ReactiveVar } from 'meteor/reactive-var'; +import { ReactiveDict } from 'meteor/reactive-dict'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; import { TAPi18n, TAPi18next } from 'meteor/tap:i18n'; import { Tracker } from 'meteor/tracker'; import _ from 'underscore'; -import s from 'underscore.string'; -import toastr from 'toastr'; -import semver from 'semver'; - -import { isEmail, APIClient } from '../../../utils'; -import { settings } from '../../../settings'; -import { Markdown } from '../../../markdown/client'; -import { modal } from '../../../ui-utils'; -import { AppEvents } from '../communication'; + +import { SideNav } from '../../../ui-utils/client'; +import { isEmail } from '../../../utils'; import { Utilities } from '../../lib/misc/Utilities'; +import { AppEvents } from '../communication'; import { Apps } from '../orchestrator'; -import { SideNav } from '../../../ui-utils/client'; +import { + appButtonProps, + appStatusSpanProps, + formatPrice, + formatPricingPlan, + handleAPIError, + triggerAppPopoverMenu, + promptSubscription, + warnStatusChange, + checkCloudLogin, +} from './helpers'; + +import './appManage.html'; +import './appManage.css'; + + +const attachAPIs = async (appId, state) => { + try { + const apis = await Apps.getAppApis(appId); + state.set('apis', apis); + } catch (error) { + handleAPIError(error); + } +}; -function getApps(instance) { - const id = instance.id.get(); - - const appInfo = { remote: undefined, local: undefined }; - return APIClient.get(`apps/${ id }?marketplace=true&version=${ FlowRouter.getQueryParam('version') }`) - .catch((e) => { - console.log(e); - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - return Promise.resolve({ app: undefined }); - }) - .then((remote) => { - if (!remote.app || !remote.app.bundledIn || remote.app.bundledIn.length === 0) { - return remote; - } +const attachSettings = async (appId, state) => { + try { + const settings = await Apps.getAppSettings(appId); - const requests = remote.app.bundledIn.map((bundledIn) => { - const request = APIClient.get(`apps/bundles/${ bundledIn.bundleId }/apps`); - - return request - .catch((e) => { - console.log(e); - return remote; - }).then((data) => { - bundledIn.apps = data && data.apps.splice(0, 4); - return remote; - }); - }); + for (const setting of Object.values(settings)) { + setting.i18nPlaceholder = setting.i18nPlaceholder || ' '; + setting.value = setting.value !== undefined && setting.value !== null ? setting.value : setting.packageValue; + setting.oldValue = setting.value; + setting.hasChanged = false; + } - return Promise.all(requests).then(() => remote); - }) - .then((remote) => { - appInfo.remote = remote.app; - return APIClient.get(`apps/${ id }`); - }) - .then((local) => { - appInfo.local = local.app; - return Apps.getAppApis(id); - }) - .then((apis) => instance.apis.set(apis)) - .catch((e) => { - if (appInfo.remote || appInfo.local) { - return Promise.resolve(true); - } + state.set('settings', settings); + } catch (error) { + handleAPIError(error); + } +}; - instance.hasError.set(true); - instance.theError.set(e.message); - }).then((goOn) => { - if (typeof goOn !== 'undefined' && !goOn) { - return; - } +const attachBundlesApps = (bundledIn, _app) => { + if (!bundledIn || !bundledIn.length) { + return; + } - if (appInfo.remote) { - appInfo.remote.displayPrice = parseFloat(appInfo.remote.price).toFixed(2); - } + bundledIn.forEach(async (bundle, i) => { + try { + const apps = await Apps.getAppsOnBundle(bundle.bundleId); + bundle.apps = apps.slice(0, 4); + } catch (error) { + handleAPIError(error); + } - if (appInfo.local) { - appInfo.local.installed = true; + bundledIn[i] = bundle; + _app.set('bundledIn', bundledIn); + }); +}; + +const attachMarketplaceInformation = async (appId, version, _app) => { + try { + const { + categories, + isPurchased, + price, + bundledIn, + purchaseType, + subscriptionInfo, + version: marketplaceVersion, + } = await Apps.getLatestAppFromMarketplace(appId, version); + + _app.set({ + categories, + isPurchased, + price, + bundledIn, + purchaseType, + subscriptionInfo, + marketplaceVersion, + }); - if (appInfo.remote) { - appInfo.local.categories = appInfo.remote.categories; - appInfo.local.isPurchased = appInfo.remote.isPurchased; - appInfo.local.price = appInfo.remote.price; - appInfo.local.displayPrice = appInfo.remote.displayPrice; - appInfo.local.bundledIn = appInfo.remote.bundledIn; + attachBundlesApps(bundledIn, _app); + } catch (error) { + if (error.xhr && error.xhr.status === 404) { + return; + } - if (semver.gt(appInfo.remote.version, appInfo.local.version) && (appInfo.remote.isPurchased || appInfo.remote.price <= 0)) { - appInfo.local.newVersion = appInfo.remote.version; - } - } + handleAPIError(error); + } +}; - instance.onSettingUpdated({ appId: id }); +const loadApp = async ({ appId, version, state, _app }) => { + let app; + try { + app = await Apps.getApp(appId); + } catch (error) { + console.error(error); + } - Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); - Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated); - Apps.getWsListener().registerListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); - Apps.getWsListener().registerListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated); - } + state.set('settings', {}); - instance.app.set(appInfo.local || appInfo.remote); - instance.ready.set(true); + if (app) { + state.set('isLoading', false); + _app.clear(); + _app.set({ ...app, installed: true }); - if (appInfo.remote && appInfo.local) { - try { - return APIClient.get(`apps/${ id }?marketplace=true&update=true&appVersion=${ FlowRouter.getQueryParam('version') }`); - } catch (e) { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - } - } + attachAPIs(appId, state); + attachSettings(appId, state); + attachMarketplaceInformation(appId, version, _app); - return Promise.resolve(false); - }).then((updateInfo) => { - if (!updateInfo) { - return; - } + if (FlowRouter.current().route.getRouteName() === 'marketplace-app') { + FlowRouter.withReplaceState(() => { + FlowRouter.go('app-manage', { appId }); + }); + return; + } - const update = updateInfo.app; + return; + } - if (semver.gt(update.version, appInfo.local.version) && (update.isPurchased || update.price <= 0)) { - appInfo.local.newVersion = update.version; + try { + app = await Apps.getAppFromMarketplace(appId, version); + } catch (error) { + state.set('error', error); + } - instance.app.set(appInfo.local); - } - }); -} - -function installAppFromEvent(e, t) { - const el = $(e.currentTarget); - el.prop('disabled', true); - el.addClass('loading'); - - const app = t.app.get(); - - const api = app.newVersion ? `apps/${ t.id.get() }` : 'apps/'; - - APIClient.post(api, { - appId: app.id, - marketplace: true, - version: app.version, - }).then(() => getApps(t)).then(() => { - el.prop('disabled', false); - el.removeClass('loading'); - }).catch((e) => { - el.prop('disabled', false); - el.removeClass('loading'); - t.hasError.set(true); - t.theError.set((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - }); + if (app) { + delete app.status; + app.marketplaceVersion = app.version; + state.set('isLoading', false); + _app.clear(); + _app.set({ ...app, installed: false }); - // play animation - // TODO this icon and animation are not working - $(e.currentTarget).find('.rc-icon').addClass('play'); -} + attachBundlesApps(app.bundledIn, _app); + + if (FlowRouter.current().route.getRouteName() === 'app-manage') { + FlowRouter.withReplaceState(() => { + FlowRouter.go('marketplace-app', { appId }); + }); + } + } +}; Template.appManage.onCreated(function() { - const instance = this; - this.id = new ReactiveVar(FlowRouter.getParam('appId')); - this.ready = new ReactiveVar(false); - this.hasError = new ReactiveVar(false); - this.theError = new ReactiveVar(''); - this.processingEnabled = new ReactiveVar(false); - this.app = new ReactiveVar({}); - this.appsList = new ReactiveVar([]); - this.settings = new ReactiveVar({}); - this.apis = new ReactiveVar([]); - this.loading = new ReactiveVar(false); - - const id = this.id.get(); - getApps(instance); + this.appId = FlowRouter.getParam('appId'); + this.version = FlowRouter.getQueryParam('version'); + this.state = new ReactiveDict({ + settings: {}, + isLoading: true, + isSaving: false, + }); + this._app = new ReactiveDict({ + id: this.appId, + version: this.version, + }); + + loadApp(this); this.__ = (key, options, lang_tag) => { - const appKey = Utilities.getI18nKeyForApp(key, id); - return TAPi18next.exists(`project:${ appKey }`) ? TAPi18n.__(appKey, options, lang_tag) : TAPi18n.__(key, options, lang_tag); + const appKey = Utilities.getI18nKeyForApp(key, this.appId); + return TAPi18next.exists(`project:${ appKey }`) + ? TAPi18n.__(appKey, options, lang_tag) + : TAPi18n.__(key, options, lang_tag); }; - function _morphSettings(settings) { - Object.keys(settings).forEach((k) => { - settings[k].i18nPlaceholder = settings[k].i18nPlaceholder || ' '; - settings[k].value = settings[k].value !== undefined && settings[k].value !== null ? settings[k].value : settings[k].packageValue; - settings[k].oldValue = settings[k].value; - settings[k].hasChanged = false; - }); - - instance.settings.set(settings); - } - - instance.onStatusChanged = function _onStatusChanged({ appId, status }) { - if (appId !== id) { + const withAppIdFilter = (f) => (maybeAppId, ...args) => { + const appId = maybeAppId.appId || maybeAppId; + if (appId !== this.appId) { return; } - const app = instance.app.get(); - app.status = status; - instance.app.set(app); + f.call(this, maybeAppId, ...args); }; - instance.onSettingUpdated = function _onSettingUpdated({ appId }) { - if (appId !== id) { - return; - } + this.handleSettingUpdated = withAppIdFilter(() => { + attachSettings(this.appId, this.state); + }); - APIClient.get(`apps/${ id }/settings`).then((result) => { - _morphSettings(result.settings); - }); - }; + this.handleChange = withAppIdFilter(() => { + loadApp(this); + }); + + Apps.getWsListener().registerListener(AppEvents.APP_ADDED, this.handleChange); + Apps.getWsListener().registerListener(AppEvents.APP_UPDATED, this.handleChange); + Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, this.handleChange); + Apps.getWsListener().registerListener(AppEvents.APP_STATUS_CHANGE, this.handleChange); + Apps.getWsListener().registerListener(AppEvents.APP_SETTING_UPDATED, this.handleSettingUpdated); }); Template.apps.onDestroyed(function() { - const instance = this; + Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, this.handleChange); + Apps.getWsListener().unregisterListener(AppEvents.APP_UPDATED, this.handleChange); + Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, this.handleChange); + Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, this.handleChange); + Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, this.handleSettingUpdated); +}); - Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); - Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated); +Template.appManage.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); }); Template.appManage.helpers({ - isEmail, - _(key, ...args) { - const options = args.pop().hash; - if (!_.isEmpty(args)) { - options.sprintf = args; - } - - return Template.instance().__(key, options); - }, - languages() { - const languages = TAPi18n.getLanguages(); - - let result = Object.keys(languages).map((key) => { - const language = languages[key]; - return _.extend(language, { key }); - }); - - result = _.sortBy(result, 'key'); - result.unshift({ - name: 'Default', - en: 'Default', - key: '', - }); - return result; - }, - appLanguage(key) { - const setting = settings.get('Language'); - return setting && setting.split('-').shift().toLowerCase() === key; - }, - selectedOption(_id, val) { - const settings = Template.instance().settings.get(); - return settings[_id].value === val; - }, - getColorVariable(color) { - return color.replace(/theme-color-/, '@'); - }, - dirty() { - const t = Template.instance(); - const settings = t.settings.get(); - return Object.keys(settings).some((k) => settings[k].hasChanged); - }, - disabled() { - const t = Template.instance(); - const settings = t.settings.get(); - return !Object.keys(settings).some((k) => settings[k].hasChanged); - }, - isReady() { - if (Template.instance().ready) { - return Template.instance().ready.get(); - } - - return false; - }, - hasError() { - if (Template.instance().hasError) { - return Template.instance().hasError.get(); - } + isSettingsPristine() { + const settings = Template.instance().state.get('settings'); + return !Object.values(settings).some(({ hasChanged }) => hasChanged); + }, + isSaving() { + return Template.instance().state.get('isSaving'); + }, + error() { + const error = Template.instance().state.get('error'); + + return error && ( + (error.xhr && error.xhr.responseJSON && error.xhr.responseJSON.error) + || error.message + ); + }, + isLoading() { + return Template.instance().state.get('isLoading'); + }, + appButtonProps, + appStatusSpanProps, + priceDisplay() { + const [purchaseType, price, pricingPlans] = [ + Template.instance()._app.get('purchaseType'), + Template.instance()._app.get('price'), + Template.instance()._app.get('pricingPlans'), + ]; + if (purchaseType === 'subscription') { + if (!pricingPlans || !Array.isArray(pricingPlans) || pricingPlans.length === 0) { + return; + } - return false; - }, - theError() { - if (Template.instance().theError) { - return Template.instance().theError.get(); + return formatPricingPlan(pricingPlans[0]); } - return ''; - }, - isProcessingEnabled() { - if (Template.instance().processingEnabled) { - return Template.instance().processingEnabled.get(); + if (price > 0) { + return formatPrice(price); } - return false; + return 'Free'; }, - isEnabled() { - if (!Template.instance().app) { - return false; - } - - const info = Template.instance().app.get(); + isEmail, + _(key, ...args) { + const [i18nArgs, keyword] = [args.slice(-2), args.slice(-1)[0]]; - return info.status === 'auto_enabled' || info.status === 'manually_enabled'; + return Template.instance().__(key, { + ...keyword.hash, + sprintf: i18nArgs, + }); }, - isInstalled() { - const instance = Template.instance(); - - return instance.app.get().installed === true; + languages() { + return [ + { + key: '', + name: 'Default', + en: 'Default', + }, + ...Object.entries(TAPi18n.getLanguages()) + .map(([key, language]) => ({ key, ...language })) + .sort(({ key: a }, { key: b }) => a.localeCompare(b)), + ]; }, - hasPurchased() { - const instance = Template.instance(); - - return instance.app.get().isPurchased === true; + selectedOption(_id, val) { + const settings = Template.instance().state.get('settings'); + return settings[_id].value === val; }, app() { - return Template.instance().app.get(); + return Template.instance()._app.all(); }, - categories() { - return Template.instance().app.get().categories; + errors() { + const { errors = {} } = Template.instance()._app.get('licenseValidation') || {}; + return Object.values(errors); + }, + warnings() { + const { warnings = {} } = Template.instance()._app.get('licenseValidation') || {}; + return Object.values(warnings); }, settings() { - return Object.values(Template.instance().settings.get()); + return Object.values(Template.instance().state.get('settings')); }, apis() { - return Template.instance().apis.get(); - }, - parseDescription(i18nDescription) { - const item = Markdown.parseMessageNotEscaped({ html: Template.instance().__(i18nDescription) }); - - item.tokens.forEach((t) => { item.html = item.html.replace(t.token, t.text); }); - - return item.html; - }, - saving() { - return Template.instance().loading.get(); + return Template.instance().state.get('apis'); }, curl(method, api) { const example = api.examples[method] || {}; @@ -346,169 +318,170 @@ Template.appManage.helpers({ }, }); -async function setActivate(actiavate, e, t) { - t.processingEnabled.set(true); - - const el = $(e.currentTarget); - el.prop('disabled', true); - - const status = actiavate ? 'manually_enabled' : 'manually_disabled'; +Template.appManage.events({ + 'click .js-cancel-editing-settings'(event, instance) { + const settings = instance.state.get('settings'); - try { - const result = await APIClient.post(`apps/${ t.id.get() }/status`, { status }); - const info = t.app.get(); - info.status = result.status; - t.app.set(info); - } catch (e) { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - } - t.processingEnabled.set(false); - el.prop('disabled', false); -} + for (const setting of Object.values(settings)) { + setting.value = setting.oldValue; + setting.hasChanged = false; + } -Template.appManage.events({ - 'click .expand': (e) => { - $(e.currentTarget).closest('.section').removeClass('section-collapsed'); - $(e.currentTarget).closest('button').removeClass('expand').addClass('collapse').find('span').text(TAPi18n.__('Collapse')); - $('.CodeMirror').each((index, codeMirror) => codeMirror.CodeMirror.refresh()); + instance.state.set('settings', settings); }, - 'click .collapse': (e) => { - $(e.currentTarget).closest('.section').addClass('section-collapsed'); - $(e.currentTarget).closest('button').addClass('expand').removeClass('collapse').find('span').text(TAPi18n.__('Expand')); - }, + async 'click .js-save-settings'(event, instance) { + event.preventDefault(); + event.stopPropagation(); - 'click .js-cancel'() { - FlowRouter.go('/admin/apps'); - }, + const { appId, state } = instance; - 'click .js-activate'(e, t) { - setActivate(true, e, t); - }, + if (state.get('isSaving')) { + return; + } - 'click .js-deactivate'(e, t) { - setActivate(false, e, t); - }, + state.set('isSaving', true); + + const settings = state.get('settings'); - 'click .js-uninstall': async (e, t) => { - t.ready.set(false); try { - await APIClient.delete(`apps/${ t.id.get() }`); - FlowRouter.go('/admin/apps'); - } catch (err) { - console.warn('Error:', err); - } finally { - t.ready.set(true); - } - }, + const toSave = Object.values(settings) + .filter(({ hasChanged }) => hasChanged); - 'click .js-install': async (e, t) => { - installAppFromEvent(e, t); - }, + if (!toSave.length) { + return; + } - 'click .js-purchase': (e, t) => { - const rl = t.app.get(); - - APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.id }`) - .then((data) => { - data.successCallback = async () => { - installAppFromEvent(e, t); - }; - - modal.open({ - allowOutsideClick: false, - data, - template: 'iframeModal', - }); - }) - .catch((e) => { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); + const updated = await Apps.setAppSettings(appId, toSave); + updated.forEach(({ id, value }) => { + settings[id].value = value; + settings[id].oldValue = value; + settings[id].hasChanged = false; }); - }, - 'click .js-update': (e, t) => { - FlowRouter.go(`/admin/app/install?isUpdatingId=${ t.id.get() }`); + state.set('settings', settings); + } catch (error) { + handleAPIError(error); + } finally { + state.set('isSaving', false); + } }, + 'click .js-close'() { + if (FlowRouter.current().route.getRouteName() === 'marketplace-app') { + FlowRouter.go('marketplace'); + return; + } + + if (FlowRouter.current().route.getRouteName() === 'app-manage') { + FlowRouter.go('apps'); + return; + } - 'click .js-view-logs': (e, t) => { - FlowRouter.go(`/admin/apps/${ t.id.get() }/logs`, {}, { version: FlowRouter.getQueryParam('version') }); + window.history.back(); }, + 'click .js-menu'(event, instance) { + event.stopPropagation(); + const { currentTarget } = event; - 'click .js-cancel-editing': async (e, t) => { - t.onSettingUpdated({ appId: t.id.get() }); + triggerAppPopoverMenu(instance._app.all(), currentTarget, instance); }, - 'click .js-save': async (e, t) => { - if (t.loading.get()) { + async 'click .js-install, click .js-update'(event, instance) { + event.stopPropagation(); + + if (!await checkCloudLogin()) { return; } - t.loading.set(true); - const settings = t.settings.get(); + const { appId, _app } = instance; - try { - const toSave = []; - Object.keys(settings).forEach((k) => { - const setting = settings[k]; - if (setting.hasChanged) { - toSave.push(setting); - } - // return !!setting.hasChanged; - }); + _app.set('working', true); - if (toSave.length === 0) { - throw new Error('Nothing to save..'); - } - const result = await APIClient.post(`apps/${ t.id.get() }/settings`, undefined, { settings: toSave }); - console.log('Updating results:', result); - result.updated.forEach((setting) => { - settings[setting.id].value = setting.value; - settings[setting.id].oldValue = setting.value; - }); - Object.keys(settings).forEach((k) => { - const setting = settings[k]; - setting.hasChanged = false; - }); - t.settings.set(settings); - } catch (e) { - console.log(e); + try { + const { status } = await Apps.installApp(appId, _app.get('marketplaceVersion')); + warnStatusChange(_app.get('name'), status); + } catch (error) { + handleAPIError(error); } finally { - t.loading.set(false); + _app.set('working', false); } }, - 'change input[type="checkbox"]': (e, t) => { - const labelFor = $(e.currentTarget).attr('name'); - const isChecked = $(e.currentTarget).prop('checked'); + async 'click .js-purchase'(event, instance) { + const { _app } = instance; - // $(`input[name="${ labelFor }"]`).prop('checked', !isChecked); + if (!await checkCloudLogin()) { + return; + } + + _app.set('working', true); - const setting = t.settings.get()[labelFor]; + const app = _app.all(); + + await promptSubscription(app, async () => { + try { + const { status } = await Apps.installApp(app.id, app.marketplaceVersion); + warnStatusChange(app.name, status); + } catch (error) { + handleAPIError(error); + } finally { + _app.set('working', false); + } + }, () => _app.set('working', false)); + }, - if (setting) { - setting.value = isChecked; - t.settings.get()[labelFor].hasChanged = setting.oldValue !== setting.value; - t.settings.set(t.settings.get()); + 'change input[type="checkbox"]'(event, instance) { + const { id } = this; + const { state } = instance; + + const settings = state.get('settings'); + const setting = settings[id]; + + if (!setting) { + return; } + + const value = event.currentTarget.checked; + + setting.value = value; + setting.hasChanged = setting.oldValue !== setting.value; + + state.set('settings', settings); }, - 'change .rc-select__element': (e, t) => { - const labelFor = $(e.currentTarget).attr('name'); - const value = $(e.currentTarget).val(); + 'change .rc-select__element'(event, instance) { + const { id } = this; + const { state } = instance; - const setting = t.settings.get()[labelFor]; + const settings = state.get('settings'); + const setting = settings[id]; - if (setting) { - setting.value = value; - t.settings.get()[labelFor].hasChanged = setting.oldValue !== setting.value; - t.settings.set(t.settings.get()); + if (!setting) { + return; } + + const { value } = event.currentTarget; + + setting.value = value; + setting.hasChanged = setting.oldValue !== setting.value; + + state.set('settings', settings); }, - 'input input, input textarea, change input[type="color"]': _.throttle(function(e, t) { - let value = s.trim($(e.target).val()); + 'input input, input textarea, change input[type="color"]': _.throttle(function(event, instance) { + const { type, id } = this; + const { state } = instance; + + const settings = state.get('settings'); + const setting = settings[id]; - switch (this.type) { + if (!setting) { + return; + } + + let value = event.currentTarget.value.trim(); + + switch (type) { case 'int': value = parseInt(value); break; @@ -516,25 +489,13 @@ Template.appManage.events({ value = value === '1'; break; case 'code': - value = $(`.code-mirror-box[data-editor-id="${ this.id }"] .CodeMirror`)[0].CodeMirror.getValue(); + value = $(`.code-mirror-box[data-editor-id="${ id }"] .CodeMirror`)[0].CodeMirror.getValue(); + break; } - const setting = t.settings.get()[this.id]; + setting.value = value; + setting.hasChanged = setting.oldValue !== setting.value; - if (setting) { - setting.value = value; - - if (setting.oldValue !== setting.value) { - t.settings.get()[this.id].hasChanged = true; - t.settings.set(t.settings.get()); - } - } + state.set('settings', settings); }, 500), }); - -Template.appManage.onRendered(() => { - Tracker.afterFlush(() => { - SideNav.setFlex('adminFlex'); - SideNav.openFlex(); - }); -}); diff --git a/app/apps/client/admin/apps.html b/app/apps/client/admin/apps.html index 96f0f9b699bd..f0d514a6855f 100644 --- a/app/apps/client/admin/apps.html +++ b/app/apps/client/admin/apps.html @@ -1,19 +1,20 @@ - + {{#header sectionName="Apps" hideHelp=true fixedHeight=true fullpage=true}} - + {{> icon icon="cloud-plus" block="rc-icon--default-size"}} {{_ "Marketplace_view_marketplace"}} - {{#if appsDevelopmentMode}} - + {{#if isDevelopmentModeEnabled}} + {{> icon icon="upload" block="rc-icon--default-size"}} {{_ "Upload_app"}} {{/if}} {{/header}} + - + {{#if isLoading}} @@ -33,33 +34,36 @@ {{#requiresPermission 'manage-apps'}} - {{#table fixed='true' onScroll=onTableScroll onResize=onTableResize onSort=onTableSort}} + {{#table fixed='true' onScroll=handleTableScroll onResize=handleTableResize onSort=handleTableSort}} - + {{_ "Name"}} {{> icon icon=(sortIcon 'name')}} - {{_ "Details"}} + {{_ "Details"}} + + + {{_ "Status"}} - {{#each apps}} - + {{#each app in apps}} + - {{#if latest.iconFileData}} - + {{#if app.iconFileData}} + {{else}} - + {{/if}} - {{latest.name}} + {{app.name}} - {{#if latest.author.name}} - by {{latest.author.name}} + {{#if app.author.name}} + by {{app.author.name}} {{/if}} @@ -68,28 +72,52 @@ - {{#if latest.summary}} - {{latest.summary}} + {{#if app.summary}} + {{app.summary}} {{else}} - {{latest.description}} + {{app.description}} {{/if}} - {{#if latest.summary}} - - {{latest.description}} - - {{/if}} - - {{formatCategories latest.categories}} + + {{#each category in app.categories}} + {{category}} + {{/each}} + + + {{#let buttonProps=(appButtonProps app) statusSpanProps=(appStatusSpanProps app)}} + {{#if buttonProps}} + + {{#if app.working}} + {{> icon icon="loading" block="rc-icon--default-size rc-apps-section__spinning-icon"}} + {{/if}} + {{_ buttonProps.label}} + + {{else if statusSpanProps}} + + {{> icon icon=statusSpanProps.icon block="rc-icon--default-size"}} + {{_ statusSpanProps.label}} + + {{/if}} + {{/let}} + + + {{> icon icon="menu" block="rc-icon--default-size"}} + + + {{/each}} {{#if isLoading}} - {{> loading}} + {{> loading}} {{/if}} diff --git a/app/apps/client/admin/apps.js b/app/apps/client/admin/apps.js index 683df77ef6a6..b98621d496bf 100644 --- a/app/apps/client/admin/apps.js +++ b/app/apps/client/admin/apps.js @@ -1,206 +1,284 @@ -import toastr from 'toastr'; -import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; +import { ReactiveDict } from 'meteor/reactive-dict'; import { Template } from 'meteor/templating'; import { Tracker } from 'meteor/tracker'; import { settings } from '../../../settings'; -import { t, APIClient } from '../../../utils'; import { AppEvents } from '../communication'; import { Apps } from '../orchestrator'; import { SideNav } from '../../../ui-utils/client'; +import { + appButtonProps, + appStatusSpanProps, + checkCloudLogin, + handleAPIError, + promptSubscription, + triggerAppPopoverMenu, + warnStatusChange, +} from './helpers'; -const ENABLED_STATUS = ['auto_enabled', 'manually_enabled']; -const enabled = ({ status }) => ENABLED_STATUS.includes(status); +import './apps.html'; -const sortByColumn = (array, column, inverted) => - array.sort((a, b) => { - if (a.latest[column] < b.latest[column] && !inverted) { - return -1; - } - return 1; + +Template.apps.onCreated(function() { + this.state = new ReactiveDict({ + apps: [], // TODO: maybe use another ReactiveDict here + isLoading: true, + searchText: '', + sortedColumn: 'name', + isAscendingOrder: true, + + // TODO: to use these fields + page: 0, + itemsPerPage: 0, + wasEndReached: false, }); -const getInstalledApps = async (instance) => { - try { - const data = await APIClient.get('apps'); - const apps = data.apps.map((app) => ({ latest: app })); + (async () => { + try { + const appsFromMarketplace = await Apps.getAppsFromMarketplace(); + const installedApps = await Apps.getApps(); - instance.apps.set(apps); - } catch (e) { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - } + const apps = installedApps.map((app) => { + const appFromMarketplace = appsFromMarketplace.find(({ id }) => id === app.id); - instance.isLoading.set(false); - instance.ready.set(true); -}; + if (!appFromMarketplace) { + return { + ...app, + installed: true, + }; + } -Template.apps.onCreated(function() { - const instance = this; - this.ready = new ReactiveVar(false); - this.apps = new ReactiveVar([]); - this.categories = new ReactiveVar([]); - this.searchText = new ReactiveVar(''); - this.searchSortBy = new ReactiveVar('name'); - this.sortDirection = new ReactiveVar('asc'); - this.limit = new ReactiveVar(0); - this.page = new ReactiveVar(0); - this.end = new ReactiveVar(false); - this.isLoading = new ReactiveVar(true); - - getInstalledApps(instance); - - try { - APIClient.get('apps?categories=true').then((data) => instance.categories.set(data)); - } catch (e) { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - } - - instance.onAppAdded = function _appOnAppAdded() { - // ToDo: fix this formatting data to add an app to installedApps array without to fetch all - - // fetch(`${ HOST }/v1/apps/${ appId }`).then((result) => { - // const installedApps = instance.installedApps.get(); - - // installedApps.push({ - // latest: result.app, - // }); - // instance.installedApps.set(installedApps); - // }); + return { + ...app, + installed: true, + categories: appFromMarketplace.categories, + marketplaceVersion: appFromMarketplace.version, + }; + }); + + this.state.set('apps', apps); + } catch (error) { + handleAPIError(error); + } finally { + this.state.set('isLoading', false); + } + })(); + + this.startAppWorking = (appId) => { + const apps = this.state.get('apps'); + const app = apps.find(({ id }) => id === appId); + app.working = true; + this.state.set('apps', apps); + }; + + this.stopAppWorking = (appId) => { + const apps = this.state.get('apps'); + const app = apps.find(({ id }) => id === appId); + delete app.working; + this.state.set('apps', apps); }; - instance.onAppRemoved = function _appOnAppRemoved(appId) { - const apps = instance.apps.get(); + this.handleAppAddedOrUpdated = async (appId) => { + try { + const app = await Apps.getApp(appId); + const { categories, version: marketplaceVersion } = await Apps.getAppFromMarketplace(appId, app.version) || {}; + const apps = [ + ...this.state.get('apps').filter(({ id }) => id !== appId), + { + ...app, + installed: true, + categories, + marketplaceVersion, + }, + ]; + this.state.set('apps', apps); + } catch (error) { + handleAPIError(error); + } + }; - let index = -1; - apps.find((item, i) => { - if (item.id === appId) { - index = i; - return true; - } - return false; - }); + this.handleAppRemoved = (appId) => { + this.state.set('apps', this.state.get('apps').filter(({ id }) => id !== appId)); + }; - apps.splice(index, 1); - instance.apps.set(apps); + this.handleAppStatusChange = ({ appId, status }) => { + const apps = this.state.get('apps'); + const app = apps.find(({ id }) => id === appId); + if (!app) { + return; + } + + app.status = status; + this.state.set('apps', apps); }; - Apps.getWsListener().registerListener(AppEvents.APP_ADDED, instance.onAppAdded); - Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, instance.onAppAdded); + Apps.getWsListener().registerListener(AppEvents.APP_ADDED, this.handleAppAddedOrUpdated); + Apps.getWsListener().registerListener(AppEvents.APP_UPDATED, this.handleAppAddedOrUpdated); + Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, this.handleAppRemoved); + Apps.getWsListener().registerListener(AppEvents.APP_STATUS_CHANGE, this.handleAppStatusChange); }); Template.apps.onDestroyed(function() { - const instance = this; + Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, this.handleAppAddedOrUpdated); + Apps.getWsListener().unregisterListener(AppEvents.APP_UPDATED, this.handleAppAddedOrUpdated); + Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, this.handleAppRemoved); + Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, this.handleAppStatusChange); +}); - Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, instance.onAppAdded); - Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, instance.onAppAdded); +Template.apps.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); }); Template.apps.helpers({ - isReady() { - if (Template.instance().ready != null) { - return Template.instance().ready.get(); - } - - return false; - }, - apps() { - const instance = Template.instance(); - const searchText = instance.searchText.get().toLowerCase(); - const sortColumn = instance.searchSortBy.get(); - const inverted = instance.sortDirection.get() === 'desc'; - return sortByColumn(instance.apps.get().filter((app) => app.latest.name.toLowerCase().includes(searchText)), sortColumn, inverted); - }, - categories() { - return Template.instance().categories.get(); - }, - appsDevelopmentMode() { + isDevelopmentModeEnabled() { return settings.get('Apps_Framework_Development_Mode') === true; }, - parseStatus(status) { - return t(`App_status_${ status }`); - }, - isActive(status) { - return enabled({ status }); - }, - sortIcon(key) { - const { - sortDirection, - searchSortBy, - } = Template.instance(); - - return key === searchSortBy.get() && sortDirection.get() !== 'asc' ? 'sort-up' : 'sort-down'; - }, - searchSortBy(key) { - return Template.instance().searchSortBy.get() === key; - }, isLoading() { - return Template.instance().isLoading.get(); + return Template.instance().state.get('isLoading'); }, - onTableScroll() { - const instance = Template.instance(); - if (instance.loading || instance.end.get()) { + handleTableScroll() { + const { state } = Template.instance(); + if (state.get('isLoading') || state.get('wasEndReached')) { return; } - return function(currentTarget) { - if (currentTarget.offsetHeight + currentTarget.scrollTop >= currentTarget.scrollHeight - 100) { - return instance.page.set(instance.page.get() + 1); + + return ({ offsetHeight, scrollTop, scrollHeight }) => { + const shouldGoToNextPage = offsetHeight + scrollTop >= scrollHeight - 100; + if (shouldGoToNextPage) { + return state.set('page', state.get('page') + 1); } }; }, - onTableResize() { - const { limit } = Template.instance(); + handleTableResize() { + const { state } = Template.instance(); return function() { - limit.set(Math.ceil((this.$('.table-scroll').height() / 40) + 5)); + const $table = this.$('.table-scroll'); + state.set('itemsPerPage', Math.ceil(($table.height() / 40) + 5)); }; }, - onTableSort() { - const { end, page, sortDirection, searchSortBy } = Template.instance(); - return function(type) { - end.set(false); - page.set(0); + handleTableSort() { + const { state } = Template.instance(); + + return (sortedColumn) => { + state.set({ + page: 0, + wasEndReached: false, + }); - if (searchSortBy.get() === type) { - sortDirection.set(sortDirection.get() === 'asc' ? 'desc' : 'asc'); + if (state.get('sortedColumn') === sortedColumn) { + state.set('isAscendingOrder', !state.get('isAscendingOrder')); return; } - searchSortBy.set(type); - sortDirection.set('asc'); + state.set({ + sortedColumn, + isAscendingOrder: true, + }); }; }, - formatCategories(categories = []) { - return categories.join(', '); + isSortingBy(column) { + return Template.instance().state.get('sortedColumn') === column; }, -}); + sortIcon(column) { + const { state } = Template.instance(); -Template.apps.events({ - 'click .manage'() { - const rl = this; + return column === state.get('sortedColumn') && state.get('isAscendingOrder') ? 'sort-down' : 'sort-up'; + }, + apps() { + const { state } = Template.instance(); + const apps = state.get('apps'); + const searchText = state.get('searchText').toLocaleLowerCase(); + const sortedColumn = state.get('sortedColumn'); + const isAscendingOrder = state.get('isAscendingOrder'); + const sortingFactor = isAscendingOrder ? 1 : -1; - if (rl && rl.latest && rl.latest.id) { - FlowRouter.go(`/admin/apps/${ rl.latest.id }?version=${ rl.latest.version }`); - } + return apps + .filter(({ name }) => name.toLocaleLowerCase().includes(searchText)) + .sort(({ [sortedColumn]: a }, { [sortedColumn]: b }) => sortingFactor * String(a).localeCompare(String(b))); }, - 'click [data-button="install_app"]'() { + appButtonProps, + appStatusSpanProps, +}); + +Template.apps.events({ + 'click .js-marketplace'() { FlowRouter.go('marketplace'); }, - 'click [data-button="upload_app"]'() { + 'click .js-upload'() { FlowRouter.go('app-install'); }, - 'keyup .js-search'(e, t) { - t.searchText.set(e.currentTarget.value); + 'submit .js-search-form'(event) { + event.stopPropagation(); + return false; }, - 'submit .js-search-form'(e) { - e.preventDefault(); - e.stopPropagation(); + 'input .js-search'(event, instance) { + instance.state.set('searchText', event.currentTarget.value); }, -}); + 'click .js-manage'(event, instance) { + event.stopPropagation(); + const { currentTarget } = event; + const { + id: appId, + version, + } = instance.state.get('apps').find(({ id }) => id === currentTarget.dataset.id); + FlowRouter.go('app-manage', { appId }, { version }); + }, + async 'click .js-install, click .js-update'(event, instance) { + event.preventDefault(); + event.stopPropagation(); -Template.apps.onRendered(() => { - Tracker.afterFlush(() => { - SideNav.setFlex('adminFlex'); - SideNav.openFlex(); - }); + if (!await checkCloudLogin()) { + return; + } + + const { currentTarget: button } = event; + const app = instance.state.get('apps').find(({ id }) => id === button.dataset.id); + + instance.startAppWorking(app.id); + + try { + const { status } = await Apps.installApp(app.id, app.marketplaceVersion); + warnStatusChange(app.name, status); + } catch (error) { + handleAPIError(error); + } finally { + instance.stopAppWorking(app.id); + } + }, + async 'click .js-purchase'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + + if (!await checkCloudLogin()) { + return; + } + + const { currentTarget: button } = event; + const app = instance.state.get('apps').find(({ id }) => id === button.dataset.id); + + instance.startAppWorking(app.id); + + await promptSubscription(app, async () => { + try { + const { status } = await Apps.installApp(app.id, app.marketplaceVersion); + warnStatusChange(app.name, status); + } catch (error) { + handleAPIError(error); + } finally { + instance.stopAppWorking(app.id); + } + }, instance.stopAppWorking.bind(instance, app.id)); + }, + 'click .js-menu'(event, instance) { + event.stopPropagation(); + const { currentTarget } = event; + + const app = instance.state.get('apps').find(({ id }) => id === currentTarget.dataset.id); + triggerAppPopoverMenu(app, currentTarget, instance); + }, }); diff --git a/app/apps/client/admin/helpers.js b/app/apps/client/admin/helpers.js new file mode 100644 index 000000000000..839974e465ed --- /dev/null +++ b/app/apps/client/admin/helpers.js @@ -0,0 +1,379 @@ +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import semver from 'semver'; +import toastr from 'toastr'; + +import { modal, popover, call } from '../../../ui-utils/client'; +import { t } from '../../../utils/client'; +import { Apps } from '../orchestrator'; + +const appEnabledStatuses = [ + AppStatus.AUTO_ENABLED, + AppStatus.MANUALLY_ENABLED, +]; + +const appErroredStatuses = [ + AppStatus.COMPILER_ERROR_DISABLED, + AppStatus.ERROR_DISABLED, + AppStatus.INVALID_SETTINGS_DISABLED, + AppStatus.INVALID_LICENSE_DISABLED, +]; + +export const handleAPIError = (error) => { + console.error(error); + const message = (error.xhr && error.xhr.responseJSON && error.xhr.responseJSON.error) || error.message; + toastr.error(message); +}; + +export const warnStatusChange = (appName, status) => { + if (appErroredStatuses.includes(status)) { + toastr.error(t(`App_status_${ status }`), appName); + return; + } + + toastr.info(t(`App_status_${ status }`), appName); +}; + +const promptCloudLogin = () => { + modal.open({ + title: t('Apps_Marketplace_Login_Required_Title'), + text: t('Apps_Marketplace_Login_Required_Description'), + type: 'info', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Login'), + cancelButtonText: t('Cancel'), + closeOnConfirm: true, + html: false, + }, (confirmed) => { + if (confirmed) { + FlowRouter.go('cloud-config'); + } + }); +}; + +export const checkCloudLogin = async () => { + try { + const isLoggedIn = await call('cloud:checkUserLoggedIn'); + + if (!isLoggedIn) { + promptCloudLogin(); + } + + return isLoggedIn; + } catch (error) { + handleAPIError(error); + return false; + } +}; + +export const promptSubscription = async (app, callback, cancelCallback) => { + let data = null; + try { + data = await Apps.buildExternalUrl(app.id, app.purchaseType, false); + } catch (error) { + handleAPIError(error); + cancelCallback(); + return; + } + + modal.open({ + allowOutsideClick: false, + data, + template: 'iframeModal', + }, callback, cancelCallback); +}; + +const promptModifySubscription = async ({ id, purchaseType }) => { + if (!await checkCloudLogin()) { + return; + } + + let data = null; + try { + data = await Apps.buildExternalUrl(id, purchaseType, true); + } catch (error) { + handleAPIError(error); + return; + } + + await new Promise((resolve) => { + modal.open({ + allowOutsideClick: false, + data, + template: 'iframeModal', + }, resolve); + }); +}; + +const promptAppDeactivation = () => new Promise((resolve) => { + modal.open({ + text: t('Apps_Marketplace_Deactivate_App_Prompt'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('No'), + closeOnConfirm: true, + html: false, + }, resolve, () => resolve(false)); +}); + +const promptAppUninstall = () => new Promise((resolve) => { + modal.open({ + text: t('Apps_Marketplace_Uninstall_App_Prompt'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('No'), + closeOnConfirm: true, + html: false, + }, resolve, () => resolve(false)); +}); + +const promptSubscribedAppUninstall = () => new Promise((resolve) => { + modal.open({ + text: t('Apps_Marketplace_Uninstall_Subscribed_App_Prompt'), + type: 'info', + showCancelButton: true, + confirmButtonText: t('Apps_Marketplace_Modify_App_Subscription'), + cancelButtonText: t('Apps_Marketplace_Uninstall_Subscribed_App_Anyway'), + cancelButtonColor: '#DD6B55', + closeOnConfirm: true, + html: false, + }, resolve, () => resolve(false)); +}); + +export const triggerAppPopoverMenu = (app, currentTarget, instance) => { + if (!app) { + return; + } + + const canAppBeSubscribed = app.purchaseType === 'subscription'; + const isSubscribed = app.subscriptionInfo && ['active', 'trialing'].includes(app.subscriptionInfo.status); + const isAppEnabled = appEnabledStatuses.includes(app.status); + + const handleSubscription = async () => { + await promptModifySubscription(app); + try { + await Apps.syncApp(app.id); + } catch (error) { + handleAPIError(error); + } + }; + + const handleViewLogs = () => { + FlowRouter.go('app-logs', { appId: app.id }, { version: app.version }); + }; + + const handleDisable = async () => { + if (!await promptAppDeactivation()) { + return; + } + + try { + const effectiveStatus = await Apps.disableApp(app.id); + warnStatusChange(app.name, effectiveStatus); + } catch (error) { + handleAPIError(error); + } + }; + + const handleEnable = async () => { + try { + const effectiveStatus = await Apps.enableApp(app.id); + warnStatusChange(app.name, effectiveStatus); + } catch (error) { + handleAPIError(error); + } + }; + + const handleUninstall = async () => { + if (isSubscribed) { + const modifySubscription = await promptSubscribedAppUninstall(); + if (modifySubscription) { + await promptModifySubscription(app); + try { + await Apps.syncApp(app.id); + } catch (error) { + handleAPIError(error); + } + return; + } + + try { + await Apps.uninstallApp(app.id); + } catch (error) { + handleAPIError(error); + } + return; + } + + if (!await promptAppUninstall()) { + return; + } + try { + await Apps.uninstallApp(app.id); + } catch (error) { + handleAPIError(error); + } + }; + + popover.open({ + currentTarget, + instance, + columns: [{ + groups: [ + { + items: [ + ...canAppBeSubscribed ? [{ + icon: 'card', + name: t('Subscription'), + action: handleSubscription, + }] : [], + { + icon: 'list-alt', + name: t('View_Logs'), + action: handleViewLogs, + }, + ], + }, + { + items: [ + isAppEnabled + ? { + icon: 'ban', + name: t('Disable'), + modifier: 'alert', + action: handleDisable, + } + : { + icon: 'check', + name: t('Enable'), + action: handleEnable, + }, + { + icon: 'trash', + name: t('Uninstall'), + modifier: 'alert', + action: handleUninstall, + }, + ], + }, + ], + }], + }); +}; + +export const appButtonProps = ({ + installed, + version, + marketplaceVersion, + isPurchased, + price, + purchaseType, + subscriptionInfo, +}) => { + const canUpdate = installed + && version && marketplaceVersion + && semver.lt(version, marketplaceVersion) + && isPurchased; + if (canUpdate) { + return { + action: 'update', + icon: 'reload', + label: 'Update', + }; + } + + if (installed) { + return; + } + + const canDownload = isPurchased; + if (canDownload) { + return { + action: 'install', + label: 'Install', + }; + } + + const canTrial = purchaseType === 'subscription' && !subscriptionInfo.status; + if (canTrial) { + return { + action: 'purchase', + label: 'Trial', + }; + } + + const canBuy = price > 0; + if (canBuy) { + return { + action: 'purchase', + label: 'Buy', + }; + } + + return { + action: 'purchase', + label: 'Install', + }; +}; + +export const appStatusSpanProps = ({ + installed, + status, + subscriptionInfo, +}) => { + if (!installed) { + return; + } + + const isFailed = appErroredStatuses.includes(status); + if (isFailed) { + return { + type: 'failed', + icon: 'warning', + label: 'Failed', + }; + } + + const isEnabled = appEnabledStatuses.includes(status); + if (!isEnabled) { + return { + type: 'warning', + icon: 'warning', + label: 'Disabled', + }; + } + + const isOnTrialPeriod = subscriptionInfo && subscriptionInfo.status === 'trialing'; + if (isOnTrialPeriod) { + return { + icon: 'checkmark-circled', + label: 'Trial period', + }; + } + + return { + icon: 'checkmark-circled', + label: 'Enabled', + }; +}; + +export const formatPrice = (price) => `\$${ Number.parseFloat(price).toFixed(2) }`; + +export const formatPricingPlan = ({ strategy, price, tiers }) => { + const { perUnit = false } = (Array.isArray(tiers) && tiers.find((tier) => tier.price === price)) || {}; + + const pricingPlanTranslationString = [ + 'Apps_Marketplace_pricingPlan', + strategy, + perUnit && 'perUser', + ].filter(Boolean).join('_'); + + return t(pricingPlanTranslationString, { + price: formatPrice(price), + }); +}; diff --git a/app/apps/client/admin/marketplace.html b/app/apps/client/admin/marketplace.html index 417f825e418a..5ee63be68ae5 100644 --- a/app/apps/client/admin/marketplace.html +++ b/app/apps/client/admin/marketplace.html @@ -1,14 +1,15 @@ - + {{#header sectionName="Marketplace" hideHelp=true fixedHeight=true fullpage=true}} - {{#unless cloudLoggedIn}} - + {{#unless isLoggedInCloud}} + {{> icon icon="cloud-plus" block="rc-icon--default-size"}} {{_ "Login"}} {{/unless}} {{/header}} + - + {{#if isLoading}} @@ -28,35 +29,39 @@ {{#requiresPermission 'manage-apps'}} - {{#table fixed='true' onScroll=onTableScroll onResize=onTableResize onSort=onTableSort}} + {{#table fixed='true' onScroll=handleTableScroll onResize=handleTableResize onSort=handleTableSort}} - + {{_ "Name"}} {{> icon icon=(sortIcon 'name')}} - {{_ "Details"}} + {{_ "Details"}} + + + {{_ "Price"}} - + + {{_ "Status"}} - {{#each apps}} - + {{#each app in apps}} + - {{#if latest.iconFileData}} - + {{#if app.iconFileData}} + {{else}} - + {{/if}} - {{latest.name}} + {{app.name}} - {{#if latest.author.name}} - by {{latest.author.name}} + {{#if app.author.name}} + by {{app.author.name}} {{/if}} @@ -65,61 +70,71 @@ - {{#if latest.summary}} - {{latest.summary}} + {{#if app.summary}} + {{app.summary}} {{else}} - {{latest.description}} + {{app.description}} {{/if}} - {{#if latest.summary}} + {{#if app.summary}} - {{latest.description}} + {{app.description}} {{/if}} - - {{formatCategories latest.categories}} + + {{#each category in app.categories}} + {{category}} + {{/each}} - - {{#if $eq latest._installed true}} - - {{_ "Installed"}} - {{> icon icon="menu" block="rc-icon--default-size"}} - - {{/if}} - {{#if renderDownloadButton latest}} - - {{#if $eq isPurchased true}} - - {{_ "Purchased"}} - {{> icon block="installer rc-icon--default-size" icon="download"}} - - {{else}} - - - {{#if $eq price 0}} - {{_ "Free"}} - {{else}} - {{ formatPrice price }} - {{/if}} - - {{> icon block="installer rc-icon--default-size" icon="circled-arrow-down"}} + + + + + {{purchaseTypeDisplay app}} + + + {{priceDisplay app}} + + + + + + + {{#let buttonProps=(appButtonProps app) statusSpanProps=(appStatusSpanProps app)}} + {{#if buttonProps}} + + {{#if app.working}} + {{> icon icon="loading" block="rc-icon--default-size rc-apps-section__spinning-icon"}} + {{/if}} + {{_ buttonProps.label}} + {{else if statusSpanProps}} + + {{> icon icon=statusSpanProps.icon block="rc-icon--default-size"}} + {{_ statusSpanProps.label}} + {{/if}} - - Downloading - {{> icon block="rc-icon--default-size rc-icon" icon="loading"}} - - - {{/if}} + {{/let}} + + {{#if app.installed}} + + {{> icon icon="menu" block="rc-icon--default-size"}} + + {{/if}} + {{/each}} {{#if isLoading}} - - {{> loading}} + + {{> loading}} {{/if}} diff --git a/app/apps/client/admin/marketplace.js b/app/apps/client/admin/marketplace.js index e06de068ab22..0907080453d2 100644 --- a/app/apps/client/admin/marketplace.js +++ b/app/apps/client/admin/marketplace.js @@ -1,347 +1,333 @@ -import toastr from 'toastr'; -import { Meteor } from 'meteor/meteor'; -import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; +import { ReactiveDict } from 'meteor/reactive-dict'; import { Template } from 'meteor/templating'; import { Tracker } from 'meteor/tracker'; -import { settings } from '../../../settings'; -import { t, APIClient } from '../../../utils'; -import { modal } from '../../../ui-utils'; +import { SideNav, call } from '../../../ui-utils/client'; +import { t } from '../../../utils'; import { AppEvents } from '../communication'; import { Apps } from '../orchestrator'; -import { SideNav } from '../../../ui-utils/client'; +import { + appButtonProps, + appStatusSpanProps, + checkCloudLogin, + formatPrice, + formatPricingPlan, + handleAPIError, + promptSubscription, + triggerAppPopoverMenu, + warnStatusChange, +} from './helpers'; -const ENABLED_STATUS = ['auto_enabled', 'manually_enabled']; -const enabled = ({ status }) => ENABLED_STATUS.includes(status); +import './marketplace.html'; -const sortByColumn = (array, column, inverted) => - array.sort((a, b) => { - if (a.latest[column] < b.latest[column] && !inverted) { - return -1; - } - return 1; + +Template.marketplace.onCreated(function() { + this.state = new ReactiveDict({ + isLoggedInCloud: true, + apps: [], // TODO: maybe use another ReactiveDict here + isLoading: true, + searchText: '', + sortedColumn: 'name', + isAscendingOrder: true, + + // TODO: to use these fields + page: 0, + itemsPerPage: 0, + wasEndReached: false, }); -const tagAlreadyInstalledApps = (installedApps, apps) => { - const installedIds = installedApps.map((app) => app.latest.id); - - const tagged = apps.map((app) => - ({ - price: app.price, - isPurchased: app.isPurchased, - latest: { - ...app.latest, - _installed: installedIds.includes(app.latest.id), - }, - }) - ); - - return tagged; -}; - -const getApps = async (instance) => { - instance.isLoading.set(true); - - try { - const data = await APIClient.get('apps?marketplace=true'); - const tagged = tagAlreadyInstalledApps(instance.installedApps.get(), data); - - instance.apps.set(tagged); - } catch (e) { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - } - - instance.isLoading.set(false); - instance.ready.set(true); -}; - -const getInstalledApps = async (instance) => { - try { - const data = await APIClient.get('apps'); - const apps = data.apps.map((app) => ({ latest: app })); - instance.installedApps.set(apps); - } catch (e) { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - } -}; - -const getCloudLoggedIn = async (instance) => { - Meteor.call('cloud:checkUserLoggedIn', (error, result) => { - if (error) { - console.warn(error); - return; + (async () => { + try { + this.state.set('isLoggedInCloud', await call('cloud:checkUserLoggedIn')); + } catch (error) { + handleAPIError(error); } - instance.cloudLoggedIn.set(result); - }); -}; + try { + const appsFromMarketplace = await Apps.getAppsFromMarketplace(); + const installedApps = await Apps.getApps(); -Template.marketplace.onCreated(function() { - const instance = this; - this.ready = new ReactiveVar(false); - this.apps = new ReactiveVar([]); - this.installedApps = new ReactiveVar([]); - this.categories = new ReactiveVar([]); - this.searchText = new ReactiveVar(''); - this.searchSortBy = new ReactiveVar('name'); - this.sortDirection = new ReactiveVar('asc'); - this.limit = new ReactiveVar(0); - this.page = new ReactiveVar(0); - this.end = new ReactiveVar(false); - this.isLoading = new ReactiveVar(true); - this.cloudLoggedIn = new ReactiveVar(false); - - getInstalledApps(instance); - getApps(instance); - - try { - APIClient.get('apps?categories=true').then((data) => instance.categories.set(data)); - } catch (e) { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - } - - instance.onAppAdded = function _appOnAppAdded() { - // ToDo: fix this formatting data to add an app to installedApps array without to fetch all - - // fetch(`${ HOST }/v1/apps/${ appId }`).then((result) => { - // const installedApps = instance.installedApps.get(); - - // installedApps.push({ - // latest: result.app, - // }); - // instance.installedApps.set(installedApps); - // }); + const apps = appsFromMarketplace.map((app) => { + const installedApp = installedApps.find(({ id }) => id === app.id); + + if (!installedApp) { + return { + ...app, + status: undefined, + marketplaceVersion: app.version, + }; + } + + return { + ...app, + installed: true, + status: installedApp.status, + version: installedApp.version, + marketplaceVersion: app.version, + }; + }); + + this.state.set('apps', apps); + } catch (error) { + handleAPIError(error); + } finally { + this.state.set('isLoading', false); + } + })(); + + this.startAppWorking = (appId) => { + const apps = this.state.get('apps'); + const app = apps.find(({ id }) => id === appId); + app.working = true; + this.state.set('apps', apps); }; - getCloudLoggedIn(instance); + this.stopAppWorking = (appId) => { + const apps = this.state.get('apps'); + const app = apps.find(({ id }) => id === appId); + delete app.working; + this.state.set('apps', apps); + }; - instance.onAppRemoved = function _appOnAppRemoved(appId) { - const apps = instance.apps.get(); + this.handleAppAddedOrUpdated = async (appId) => { + try { + const { status, version } = await Apps.getApp(appId); + const app = await Apps.getAppFromMarketplace(appId, version); + const apps = [ + ...this.state.get('apps').filter(({ id }) => id !== appId), + { + ...app, + installed: true, + status, + version, + marketplaceVersion: app.version, + }, + ]; + this.state.set('apps', apps); + } catch (error) { + handleAPIError(error); + } + }; - let index = -1; - apps.find((item, i) => { - if (item.id === appId) { - index = i; - return true; + this.handleAppRemoved = (appId) => { + const apps = this.state.get('apps').map((app) => { + if (app.id === appId) { + delete app.installed; + delete app.status; + app.version = app.marketplaceVersion; } - return false; + + return app; }); + this.state.set('apps', apps); + }; - apps.splice(index, 1); - instance.apps.set(apps); + this.handleAppStatusChange = ({ appId, status }) => { + const apps = this.state.get('apps'); + const app = apps.find(({ id }) => id === appId); + if (!app) { + return; + } + + app.status = status; + this.state.set('apps', apps); }; - Apps.getWsListener().registerListener(AppEvents.APP_ADDED, instance.onAppAdded); - Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, instance.onAppAdded); + Apps.getWsListener().registerListener(AppEvents.APP_ADDED, this.handleAppAddedOrUpdated); + Apps.getWsListener().registerListener(AppEvents.APP_UPDATED, this.handleAppAddedOrUpdated); + Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, this.handleAppRemoved); + Apps.getWsListener().registerListener(AppEvents.APP_STATUS_CHANGE, this.handleAppStatusChange); }); Template.marketplace.onDestroyed(function() { - const instance = this; + Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, this.handleAppAddedOrUpdated); + Apps.getWsListener().unregisterListener(AppEvents.APP_UPDATED, this.handleAppAddedOrUpdated); + Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, this.handleAppRemoved); + Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, this.handleAppStatusChange); +}); - Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, instance.onAppAdded); - Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, instance.onAppAdded); +Template.marketplace.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); }); Template.marketplace.helpers({ - isReady() { - if (Template.instance().ready != null) { - return Template.instance().ready.get(); - } - - return false; - }, - apps() { - const instance = Template.instance(); - const searchText = instance.searchText.get().toLowerCase(); - const sortColumn = instance.searchSortBy.get(); - const inverted = instance.sortDirection.get() === 'desc'; - return sortByColumn(instance.apps.get().filter((app) => app.latest.name.toLowerCase().includes(searchText)), sortColumn, inverted); - }, - categories() { - return Template.instance().categories.get(); - }, - appsDevelopmentMode() { - return settings.get('Apps_Framework_Development_Mode') === true; - }, - cloudLoggedIn() { - return Template.instance().cloudLoggedIn.get(); - }, - parseStatus(status) { - return t(`App_status_${ status }`); - }, - isActive(status) { - return enabled({ status }); - }, - sortIcon(key) { - const { - sortDirection, - searchSortBy, - } = Template.instance(); - - return key === searchSortBy.get() && sortDirection.get() !== 'asc' ? 'sort-up' : 'sort-down'; - }, - searchSortBy(key) { - return Template.instance().searchSortBy.get() === key; + isLoggedInCloud() { + return Template.instance().state.get('isLoggedInCloud'); }, isLoading() { - return Template.instance().isLoading.get(); + return Template.instance().state.get('isLoading'); }, - onTableScroll() { - const instance = Template.instance(); - if (instance.loading || instance.end.get()) { + handleTableScroll() { + const { state } = Template.instance(); + if (state.get('isLoading') || state.get('wasEndReached')) { return; } - return function(currentTarget) { - if (currentTarget.offsetHeight + currentTarget.scrollTop >= currentTarget.scrollHeight - 100) { - return instance.page.set(instance.page.get() + 1); + + return ({ offsetHeight, scrollTop, scrollHeight }) => { + const shouldGoToNextPage = offsetHeight + scrollTop >= scrollHeight - 100; + if (shouldGoToNextPage) { + return state.set('page', state.get('page') + 1); } }; }, - onTableResize() { - const { limit } = Template.instance(); + handleTableResize() { + const { state } = Template.instance(); return function() { - limit.set(Math.ceil((this.$('.table-scroll').height() / 40) + 5)); + const $table = this.$('.table-scroll'); + state.set('itemsPerPage', Math.ceil(($table.height() / 40) + 5)); }; }, - onTableSort() { - const { end, page, sortDirection, searchSortBy } = Template.instance(); - return function(type) { - end.set(false); - page.set(0); - - if (searchSortBy.get() === type) { - sortDirection.set(sortDirection.get() === 'asc' ? 'desc' : 'asc'); + handleTableSort() { + const { state } = Template.instance(); + + return (sortedColumn) => { + state.set({ + page: 0, + wasEndReached: false, + }); + + if (state.get('sortedColumn') === sortedColumn) { + state.set('isAscendingOrder', !state.get('isAscendingOrder')); return; } - searchSortBy.set(type); - sortDirection.set('asc'); + state.set({ + sortedColumn, + isAscendingOrder: true, + }); }; }, - renderDownloadButton(latest) { - return latest._installed === false; + isSortingBy(column) { + return Template.instance().state.get('sortedColumn') === column; }, - formatPrice(price) { - return `$${ Number.parseFloat(price).toFixed(2) }`; + sortIcon(column) { + const { state } = Template.instance(); + + return column === state.get('sortedColumn') && state.get('isAscendingOrder') ? 'sort-down' : 'sort-up'; }, - formatCategories(categories = []) { - return categories.join(', '); + apps() { + const { state } = Template.instance(); + const apps = state.get('apps'); + const searchText = state.get('searchText').toLocaleLowerCase(); + const sortedColumn = state.get('sortedColumn'); + const isAscendingOrder = state.get('isAscendingOrder'); + const sortingFactor = isAscendingOrder ? 1 : -1; + + return apps + .filter(({ name }) => name.toLocaleLowerCase().includes(searchText)) + .sort(({ [sortedColumn]: a }, { [sortedColumn]: b }) => sortingFactor * String(a).localeCompare(String(b))); }, -}); + purchaseTypeDisplay({ purchaseType, price }) { + if (purchaseType === 'subscription') { + return t('Subscription'); + } -Template.marketplace.events({ - 'click .manage'() { - const rl = this; + if (price > 0) { + return t('Paid'); + } + + return t('Free'); + }, + priceDisplay({ purchaseType, pricingPlans, price }) { + if (purchaseType === 'subscription') { + if (!pricingPlans || !Array.isArray(pricingPlans) || pricingPlans.length === 0) { + return '-'; + } - if (rl && rl.latest && rl.latest.id) { - FlowRouter.go(`/admin/apps/${ rl.latest.id }?version=${ rl.latest.version }`); + return formatPricingPlan(pricingPlans[0]); } + + if (price > 0) { + return formatPrice(price); + } + + return '-'; }, - 'click [data-button="install"]'() { - FlowRouter.go('/admin/app/install'); + appButtonProps, + appStatusSpanProps, +}); + +Template.marketplace.events({ + 'click .js-cloud-login'() { + FlowRouter.go('cloud-config'); }, - 'click [data-button="login"]'() { - FlowRouter.go('/admin/cloud'); + 'submit .js-search-form'(event) { + event.stopPropagation(); + return false; }, - 'click .js-install'(e, template) { - e.stopPropagation(); - const elm = e.currentTarget.parentElement; - - elm.classList.add('loading'); - - APIClient.post('apps/', { - appId: this.latest.id, - marketplace: true, - version: this.latest.version, - }) - .then(async () => { - await Promise.all([ - getInstalledApps(template), - getApps(template), - ]); - elm.classList.remove('loading'); - }) - .catch((e) => { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - elm.classList.remove('loading'); - }); + 'keyup .js-search'(event, instance) { + instance.state.set('searchText', event.currentTarget.value); }, - 'click .js-purchase'(e, template) { - e.stopPropagation(); - - const rl = this; - - if (!template.cloudLoggedIn.get()) { - modal.open({ - title: t('Apps_Marketplace_Login_Required_Title'), - text: t('Apps_Marketplace_Login_Required_Description'), - type: 'info', - showCancelButton: true, - confirmButtonColor: '#DD6B55', - confirmButtonText: t('Login'), - cancelButtonText: t('Cancel'), - closeOnConfirm: true, - html: false, - }, function(confirmed) { - if (confirmed) { - FlowRouter.go('/admin/cloud'); - } - }); + 'click .js-open'(event, instance) { + event.stopPropagation(); + const { currentTarget } = event; + const { + id: appId, + version, + marketplaceVersion, + } = instance.state.get('apps').find(({ id }) => id === currentTarget.dataset.id); + FlowRouter.go('marketplace-app', { appId }, { version: version || marketplaceVersion }); + }, + async 'click .js-install, click .js-update'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + + const isLoggedInCloud = await checkCloudLogin(); + instance.state.set('isLoggedInCloud', isLoggedInCloud); + if (!isLoggedInCloud) { return; } - // play animation - const elm = e.currentTarget.parentElement; - - APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.latest.id }`) - .then((data) => { - modal.open({ - allowOutsideClick: false, - data, - template: 'iframeModal', - }, () => { - elm.classList.add('loading'); - APIClient.post('apps/', { - appId: this.latest.id, - marketplace: true, - version: this.latest.version, - }) - .then(async () => { - await Promise.all([ - getInstalledApps(template), - getApps(template), - ]); - elm.classList.remove('loading'); - }) - .catch((e) => { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - elm.classList.remove('loading'); - }); - }); - }) - .catch((e) => { - const errMsg = (e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message; - toastr.error(errMsg); - - if (errMsg === 'Unauthorized') { - getCloudLoggedIn(template); - } - }); - }, - 'keyup .js-search'(e, t) { - t.searchText.set(e.currentTarget.value); + const { currentTarget: button } = event; + const app = instance.state.get('apps').find(({ id }) => id === button.dataset.id); + + instance.startAppWorking(app.id); + + try { + const { status } = await Apps.installApp(app.id, app.marketplaceVersion); + warnStatusChange(app.name, status); + } catch (error) { + handleAPIError(error); + } finally { + instance.stopAppWorking(app.id); + } }, - 'submit .js-search-form'(e) { - e.preventDefault(); - e.stopPropagation(); + async 'click .js-purchase'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + + const isLoggedInCloud = await checkCloudLogin(); + instance.state.set('isLoggedInCloud', isLoggedInCloud); + if (!isLoggedInCloud) { + return; + } + + const { currentTarget: button } = event; + const app = instance.state.get('apps').find(({ id }) => id === button.dataset.id); + + instance.startAppWorking(app.id); + + await promptSubscription(app, async () => { + try { + const { status } = await Apps.installApp(app.id, app.marketplaceVersion); + warnStatusChange(app.name, status); + } catch (error) { + handleAPIError(error); + } finally { + instance.stopAppWorking(app.id); + } + }, instance.stopAppWorking.bind(instance, app.id)); }, -}); + 'click .js-menu'(event, instance) { + event.stopPropagation(); + const { currentTarget } = event; -Template.marketplace.onRendered(() => { - Tracker.afterFlush(() => { - SideNav.setFlex('adminFlex'); - SideNav.openFlex(); - }); + const app = instance.state.get('apps').find(({ id }) => id === currentTarget.dataset.id); + triggerAppPopoverMenu(app, currentTarget, instance); + }, }); diff --git a/app/apps/client/communication/index.js b/app/apps/client/communication/index.js index 8878b65fcf21..321bbb7f15b7 100644 --- a/app/apps/client/communication/index.js +++ b/app/apps/client/communication/index.js @@ -1,3 +1 @@ -import { AppWebsocketReceiver, AppEvents } from './websockets'; - -export { AppWebsocketReceiver, AppEvents }; +export { AppWebsocketReceiver, AppEvents } from './websockets'; diff --git a/app/apps/client/communication/websockets.js b/app/apps/client/communication/websockets.js index 1da0efecccf2..aa1a23d451cb 100644 --- a/app/apps/client/communication/websockets.js +++ b/app/apps/client/communication/websockets.js @@ -1,4 +1,5 @@ import { Meteor } from 'meteor/meteor'; +import EventEmitter from 'wolfy87-eventemitter'; import { slashCommands, APIClient } from '../../../utils'; import { CachedCollectionManager } from '../../../ui-cached-collection'; @@ -15,79 +16,43 @@ export const AppEvents = Object.freeze({ COMMAND_REMOVED: 'command/removed', }); -export class AppWebsocketReceiver { - constructor(orch) { - this.orch = orch; +export class AppWebsocketReceiver extends EventEmitter { + constructor() { + super(); + this.streamer = new Meteor.Streamer('apps'); CachedCollectionManager.onLogin(() => { this.listenStreamerEvents(); }); - - this.listeners = {}; - - Object.keys(AppEvents).forEach((v) => { - this.listeners[AppEvents[v]] = []; - }); } listenStreamerEvents() { - this.streamer.on(AppEvents.APP_ADDED, this.onAppAdded.bind(this)); - this.streamer.on(AppEvents.APP_REMOVED, this.onAppRemoved.bind(this)); - this.streamer.on(AppEvents.APP_UPDATED, this.onAppUpdated.bind(this)); - this.streamer.on(AppEvents.APP_STATUS_CHANGE, this.onAppStatusUpdated.bind(this)); - this.streamer.on(AppEvents.APP_SETTING_UPDATED, this.onAppSettingUpdated.bind(this)); - this.streamer.on(AppEvents.COMMAND_ADDED, this.onCommandAdded.bind(this)); - this.streamer.on(AppEvents.COMMAND_DISABLED, this.onCommandDisabled.bind(this)); - this.streamer.on(AppEvents.COMMAND_UPDATED, this.onCommandUpdated.bind(this)); - this.streamer.on(AppEvents.COMMAND_REMOVED, this.onCommandDisabled.bind(this)); - } - - registerListener(event, listener) { - this.listeners[event].push(listener); - } - - unregisterListener(event, listener) { - this.listeners[event].splice(this.listeners[event].indexOf(listener), 1); - } - - onAppAdded(appId) { - APIClient.get(`apps/${ appId }/languages`).then((result) => { - this.orch.parseAndLoadLanguages(result.languages, appId); + Object.values(AppEvents).forEach((eventName) => { + this.streamer.on(eventName, this.emit.bind(this, eventName)); }); - this.listeners[AppEvents.APP_ADDED].forEach((listener) => listener(appId)); + this.streamer.on(AppEvents.COMMAND_ADDED, this.onCommandAddedOrUpdated); + this.streamer.on(AppEvents.COMMAND_UPDATED, this.onCommandAddedOrUpdated); + this.streamer.on(AppEvents.COMMAND_REMOVED, this.onCommandRemovedOrDisabled); + this.streamer.on(AppEvents.COMMAND_DISABLED, this.onCommandRemovedOrDisabled); } - onAppRemoved(appId) { - this.listeners[AppEvents.APP_REMOVED].forEach((listener) => listener(appId)); - } - - onAppUpdated(appId) { - this.listeners[AppEvents.APP_UPDATED].forEach((listener) => listener(appId)); - } - - onAppStatusUpdated({ appId, status }) { - this.listeners[AppEvents.APP_STATUS_CHANGE].forEach((listener) => listener({ appId, status })); + registerListener(event, listener) { + this.on(event, listener); } - onAppSettingUpdated({ appId }) { - this.listeners[AppEvents.APP_SETTING_UPDATED].forEach((listener) => listener({ appId })); + unregisterListener(event, listener) { + this.off(event, listener); } - onCommandAdded(command) { + onCommandAddedOrUpdated = (command) => { APIClient.v1.get('commands.get', { command }).then((result) => { slashCommands.commands[command] = result.command; }); } - onCommandDisabled(command) { + onCommandRemovedOrDisabled = (command) => { delete slashCommands.commands[command]; } - - onCommandUpdated(command) { - APIClient.v1.get('commands.get', { command }).then((result) => { - slashCommands.commands[command] = result.command; - }); - } } diff --git a/app/apps/client/i18n.js b/app/apps/client/i18n.js new file mode 100644 index 000000000000..3a33d17f111b --- /dev/null +++ b/app/apps/client/i18n.js @@ -0,0 +1,38 @@ +import { TAPi18next } from 'meteor/tap:i18n'; + +import { Apps } from './orchestrator'; +import { Utilities } from '../lib/misc/Utilities'; +import { AppEvents } from './communication'; + + +export const loadAppI18nResources = (appId, languages) => { + Object.entries(languages).forEach(([language, translations]) => { + try { + // Translations keys must be scoped under app id + const scopedTranslations = Object.entries(translations) + .reduce((translations, [key, value]) => { + translations[Utilities.getI18nKeyForApp(key, appId)] = value; + return translations; + }, {}); + + TAPi18next.addResourceBundle(language, 'project', scopedTranslations); + } catch (error) { + Apps.handleError(error); + } + }); +}; + +const handleAppAdded = async (appId) => { + const languages = await Apps.getAppLanguages(appId); + loadAppI18nResources(appId, languages); +}; + +export const handleI18nResources = async () => { + const apps = await Apps.getAppsLanguages(); + apps.forEach(({ id, languages }) => { + loadAppI18nResources(id, languages); + }); + + Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, handleAppAdded); + Apps.getWsListener().registerListener(AppEvents.APP_ADDED, handleAppAdded); +}; diff --git a/app/apps/client/index.js b/app/apps/client/index.js index 4ee0430ecc5f..887964154a90 100644 --- a/app/apps/client/index.js +++ b/app/apps/client/index.js @@ -1,16 +1,14 @@ import './admin/modalTemplates/iframeModal.html'; import './admin/modalTemplates/iframeModal'; -import './admin/marketplace.html'; import './admin/marketplace'; -import './admin/apps.html'; import './admin/apps'; import './admin/appInstall.html'; import './admin/appInstall'; import './admin/appLogs.html'; import './admin/appLogs'; -import './admin/appManage.html'; import './admin/appManage'; import './admin/appWhatIsIt.html'; import './admin/appWhatIsIt'; +import './routes'; export { Apps } from './orchestrator'; diff --git a/app/apps/client/orchestrator.js b/app/apps/client/orchestrator.js index 0ade72a4811a..28b06e558de4 100644 --- a/app/apps/client/orchestrator.js +++ b/app/apps/client/orchestrator.js @@ -1,190 +1,188 @@ import { Meteor } from 'meteor/meteor'; -import { FlowRouter } from 'meteor/kadira:flow-router'; -import { BlazeLayout } from 'meteor/kadira:blaze-layout'; -import { TAPi18next } from 'meteor/tap:i18n'; +import toastr from 'toastr'; import { AppWebsocketReceiver } from './communication'; -import { Utilities } from '../lib/misc/Utilities'; import { APIClient } from '../../utils'; import { AdminBox } from '../../ui-utils'; import { CachedCollectionManager } from '../../ui-cached-collection'; import { hasAtLeastOnePermission } from '../../authorization'; +import { handleI18nResources } from './i18n'; + +const createDeferredValue = () => { + let resolve; + let reject; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); -export let Apps; + return [promise, resolve, reject]; +}; class AppClientOrchestrator { constructor() { - this._isLoaded = false; - this._isEnabled = false; - this._loadingResolve; - this._refreshLoading(); - } - - isLoaded() { - return this._isLoaded; + this.isLoaded = false; + [this.deferredIsEnabled, this.setEnabled] = createDeferredValue(); } - isEnabled() { - return this._isEnabled; - } - - getLoadingPromise() { - if (this._isLoaded) { - return Promise.resolve(this._isEnabled); + load = async (isEnabled) => { + if (!this.isLoaded) { + this.ws = new AppWebsocketReceiver(); + this.registerAdminMenuItems(); + this.isLoaded = true; } - return this._loadingPromise; - } - - load(isEnabled) { - console.log('Loading:', isEnabled); - this._isEnabled = isEnabled; + this.setEnabled(isEnabled); - // It was already loaded, so let's load it again - if (this._isLoaded) { - this._refreshLoading(); - } else { - this.ws = new AppWebsocketReceiver(this); - this._addAdminMenuOption(); - } - - Meteor.defer(() => { - this._loadLanguages().then(() => { - this._loadingResolve(this._isEnabled); - this._isLoaded = true; - }); - }); - } + // Since the deferred value (a promise) is immutable after resolved, + // it need to be recreated to resolve a new value + [this.deferredIsEnabled, this.setEnabled] = createDeferredValue(); - getWsListener() { - return this.ws; + await handleI18nResources(); + this.setEnabled(isEnabled); } - _refreshLoading() { - this._loadingPromise = new Promise((resolve) => { - this._loadingResolve = resolve; - }); - } + getWsListener = () => this.ws - _addAdminMenuOption() { + registerAdminMenuItems = () => { AdminBox.addOption({ icon: 'cube', href: 'apps', i18nLabel: 'Apps', - permissionGranted() { - return hasAtLeastOnePermission(['manage-apps']); - }, + permissionGranted: () => hasAtLeastOnePermission(['manage-apps']), }); AdminBox.addOption({ icon: 'cube', href: 'marketplace', i18nLabel: 'Marketplace', - permissionGranted() { - return hasAtLeastOnePermission(['manage-apps']); - }, + permissionGranted: () => hasAtLeastOnePermission(['manage-apps']), }); } - _loadLanguages() { - return APIClient.get('apps/languages').then((info) => { - info.apps.forEach((rlInfo) => this.parseAndLoadLanguages(rlInfo.languages, rlInfo.id)); - }); + handleError = (error) => { + console.error(error); + if (hasAtLeastOnePermission(['manage-apps'])) { + toastr.error(error.message); + } } - parseAndLoadLanguages(languages, id) { - Object.entries(languages).forEach(([language, translations]) => { - try { - translations = Object.entries(translations).reduce((newTranslations, [key, value]) => { - newTranslations[Utilities.getI18nKeyForApp(key, id)] = value; - return newTranslations; - }, {}); + isEnabled = () => this.deferredIsEnabled - TAPi18next.addResourceBundle(language, 'project', translations); - } catch (e) { - // Failed to parse the json - } + getApps = async () => { + const { apps } = await APIClient.get('apps'); + return apps; + } + + getAppsFromMarketplace = async () => { + const appsOverviews = await APIClient.get('apps', { marketplace: 'true' }); + return appsOverviews.map(({ latest, price, pricingPlans, purchaseType }) => ({ + ...latest, + price, + pricingPlans, + purchaseType, + })); + } + + getAppsOnBundle = async (bundleId) => { + const { apps } = await APIClient.get(`apps/bundles/${ bundleId }/apps`); + return apps; + } + + getAppsLanguages = async () => { + const { apps } = await APIClient.get('apps/languages'); + return apps; + } + + getApp = async (appId) => { + const { app } = await APIClient.get(`apps/${ appId }`); + return app; + } + + getAppFromMarketplace = async (appId, version) => { + const { app } = await APIClient.get(`apps/${ appId }`, { + marketplace: 'true', + version, }); + return app; } - async getAppApis(appId) { - const result = await APIClient.get(`apps/${ appId }/apis`); - return result.apis; + getLatestAppFromMarketplace = async (appId, version) => { + const { app } = await APIClient.get(`apps/${ appId }`, { + marketplace: 'true', + update: 'true', + appVersion: version, + }); + return app; + } + + getAppSettings = async (appId) => { + const { settings } = await APIClient.get(`apps/${ appId }/settings`); + return settings; } -} -Meteor.startup(function _rlClientOrch() { - Apps = new AppClientOrchestrator(); + setAppSettings = async (appId, settings) => { + const { updated } = await APIClient.post(`apps/${ appId }/settings`, undefined, { settings }); + return updated; + } - CachedCollectionManager.onLogin(() => { - Meteor.call('apps/is-enabled', (error, isEnabled) => { - Apps.load(isEnabled); + getAppApis = async (appId) => { + const { apis } = await APIClient.get(`apps/${ appId }/apis`); + return apis; + } + + getAppLanguages = async (appId) => { + const { languages } = await APIClient.get(`apps/${ appId }/languages`); + return languages; + } + + installApp = async (appId, version) => { + const { app } = await APIClient.post('apps/', { + appId, + marketplace: true, + version, }); - }); -}); + return app; + } -const appsRouteAction = function _theRealAction(whichCenter) { - Meteor.defer(() => Apps.getLoadingPromise().then((isEnabled) => { - if (isEnabled) { - BlazeLayout.render('main', { center: whichCenter, old: true }); // TODO remove old - } else { - FlowRouter.go('app-what-is-it'); - } - })); -}; + uninstallApp = (appId) => APIClient.delete(`apps/${ appId }`) -// Bah, this has to be done *before* `Meteor.startup` -FlowRouter.route('/admin/marketplace', { - name: 'marketplace', - action() { - appsRouteAction('marketplace'); - }, -}); + syncApp = (appId) => APIClient.post(`apps/${ appId }/sync`) -FlowRouter.route('/admin/marketplace/:itemId', { - name: 'app-manage', - action() { - appsRouteAction('appManage'); - }, -}); + setAppStatus = async (appId, status) => { + const { status: effectiveStatus } = await APIClient.post(`apps/${ appId }/status`, { status }); + return effectiveStatus; + } -FlowRouter.route('/admin/apps', { - name: 'apps', - action() { - appsRouteAction('apps'); - }, -}); + enableApp = (appId) => this.setAppStatus(appId, 'manually_enabled') -FlowRouter.route('/admin/app/install', { - name: 'app-install', - action() { - appsRouteAction('appInstall'); - }, -}); + disableApp = (appId) => this.setAppStatus(appId, 'manually_disabled') -FlowRouter.route('/admin/apps/:appId', { - name: 'app-manage', - action() { - appsRouteAction('appManage'); - }, -}); + buildExternalUrl = (appId, purchaseType = 'buy', details = false) => + APIClient.get('apps', { + buildExternalUrl: 'true', + appId, + purchaseType, + details, + }) -FlowRouter.route('/admin/apps/:appId/logs', { - name: 'app-logs', - action() { - appsRouteAction('appLogs'); - }, -}); + getCategories = async () => { + const categories = await APIClient.get('apps', { categories: 'true' }); + return categories; + } +} + +export const Apps = new AppClientOrchestrator(); -FlowRouter.route('/admin/app/what-is-it', { - name: 'app-what-is-it', - action() { - Meteor.defer(() => Apps.getLoadingPromise().then((isEnabled) => { - if (isEnabled) { - FlowRouter.go('apps'); - } else { - BlazeLayout.render('main', { center: 'appWhatIsIt' }); +Meteor.startup(() => { + CachedCollectionManager.onLogin(() => { + Meteor.call('apps/is-enabled', (error, isEnabled) => { + if (error) { + Apps.handleError(error); + return; } - })); - }, + + Apps.load(isEnabled); + }); + }); }); diff --git a/app/apps/client/routes.js b/app/apps/client/routes.js new file mode 100644 index 000000000000..1a44f4d74bff --- /dev/null +++ b/app/apps/client/routes.js @@ -0,0 +1,55 @@ +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; + +import { Apps } from './orchestrator'; + +FlowRouter.route('/admin/apps/what-is-it', { + name: 'apps-what-is-it', + action: async () => { + // TODO: render loading indicator + if (await Apps.isEnabled()) { + FlowRouter.go('apps'); + } else { + BlazeLayout.render('main', { center: 'appWhatIsIt' }); + } + }, +}); + +const createAppsRouteAction = (centerTemplate) => async () => { + // TODO: render loading indicator + if (await Apps.isEnabled()) { + BlazeLayout.render('main', { center: centerTemplate, old: true }); // TODO remove old + } else { + FlowRouter.go('apps-what-is-it'); + } +}; + +FlowRouter.route('/admin/apps', { + name: 'apps', + action: createAppsRouteAction('apps'), +}); + +FlowRouter.route('/admin/apps/install', { + name: 'app-install', + action: createAppsRouteAction('appInstall'), +}); + +FlowRouter.route('/admin/apps/:appId', { + name: 'app-manage', + action: createAppsRouteAction('appManage'), +}); + +FlowRouter.route('/admin/apps/:appId/logs', { + name: 'app-logs', + action: createAppsRouteAction('appLogs'), +}); + +FlowRouter.route('/admin/marketplace', { + name: 'marketplace', + action: createAppsRouteAction('marketplace'), +}); + +FlowRouter.route('/admin/marketplace/:appId', { + name: 'marketplace-app', + action: createAppsRouteAction('appManage'), +}); diff --git a/app/apps/server/bridges/internal.js b/app/apps/server/bridges/internal.js index 2a41c5829897..ef883a68437f 100644 --- a/app/apps/server/bridges/internal.js +++ b/app/apps/server/bridges/internal.js @@ -1,4 +1,4 @@ -import { Subscriptions } from '../../../models'; +import { Subscriptions, Settings } from '../../../models'; export class AppInternalBridge { constructor(orch) { @@ -18,4 +18,10 @@ export class AppInternalBridge { return records.map((s) => s.u.username); } + + getWorkspacePublicKey() { + const publicKeySetting = Settings.findById('Cloud_Workspace_PublicKey').fetch()[0]; + + return this.orch.getConverters().get('settings').convertToApp(publicKeySetting); + } } diff --git a/app/apps/server/bridges/users.js b/app/apps/server/bridges/users.js index dae08b2d02d9..7329777f7026 100644 --- a/app/apps/server/bridges/users.js +++ b/app/apps/server/bridges/users.js @@ -1,3 +1,5 @@ +import { Users } from '../../../models/server'; + export class AppUserBridge { constructor(orch) { this.orch = orch; @@ -14,4 +16,8 @@ export class AppUserBridge { return this.orch.getConverters().get('users').convertByUsername(username); } + + async getActiveUserCount() { + return Users.getActiveLocalUserCount(); + } } diff --git a/app/apps/server/communication/rest.js b/app/apps/server/communication/rest.js index 2d22abb2b2cf..5b0a4f19d40b 100644 --- a/app/apps/server/communication/rest.js +++ b/app/apps/server/communication/rest.js @@ -6,6 +6,14 @@ import { API } from '../../../api/server'; import { getWorkspaceAccessToken, getUserCloudAccessToken } from '../../../cloud/server'; import { settings } from '../../../settings'; import { Info } from '../../../utils'; +import { Settings, Users } from '../../../models/server'; +import { Apps } from '../orchestrator'; + +const getDefaultHeaders = () => ({ + 'X-Apps-Engine-Version': Info.marketplaceApiVersion, +}); + +const purchaseTypes = new Set(['buy', 'subscription']); export class AppsRestApi { constructor(orch, manager) { @@ -57,17 +65,24 @@ export class AppsRestApi { // Gets the Apps from the marketplace if (this.queryParams.marketplace) { - const headers = {}; + const headers = getDefaultHeaders(); const token = getWorkspaceAccessToken(); if (token) { headers.Authorization = `Bearer ${ token }`; } - const result = HTTP.get(`${ baseUrl }/v1/apps?version=${ Info.marketplaceApiVersion }`, { - headers, - }); + let result; + try { + result = HTTP.get(`${ baseUrl }/v1/apps`, { + headers, + }); + } catch (e) { + orchestrator.getRocketChatLogger().error('Error getting the Apps:', e.response.data); + return API.v1.internalError(); + } - if (result.statusCode !== 200) { + if (!result || result.statusCode !== 200) { + orchestrator.getRocketChatLogger().error('Error getting the Apps:', result.data); return API.v1.failure(); } @@ -75,32 +90,51 @@ export class AppsRestApi { } if (this.queryParams.categories) { - const headers = {}; + const headers = getDefaultHeaders(); const token = getWorkspaceAccessToken(); if (token) { headers.Authorization = `Bearer ${ token }`; } - const result = HTTP.get(`${ baseUrl }/v1/categories`, { - headers, - }); + let result; + try { + result = HTTP.get(`${ baseUrl }/v1/categories`, { + headers, + }); + } catch (e) { + orchestrator.getRocketChatLogger().error('Error getting the categories from the Marketplace:', e.response.data); + return API.v1.internalError(); + } - if (result.statusCode !== 200) { + if (!result || result.statusCode !== 200) { + orchestrator.getRocketChatLogger().error('Error getting the categories from the Marketplace:', result.data); return API.v1.failure(); } return API.v1.success(result.data); } - if (this.queryParams.buildBuyUrl && this.queryParams.appId) { + if (this.queryParams.buildExternalUrl && this.queryParams.appId) { const workspaceId = settings.get('Cloud_Workspace_Id'); + if (!this.queryParams.purchaseType || !purchaseTypes.has(this.queryParams.purchaseType)) { + return API.v1.failure({ error: 'Invalid purchase type' }); + } + const token = getUserCloudAccessToken(this.getLoggedInUser()._id, true, 'marketplace:purchase', false); if (!token) { return API.v1.failure({ error: 'Unauthorized' }); } - return API.v1.success({ url: `${ baseUrl }/apps/${ this.queryParams.appId }/buy?workspaceId=${ workspaceId }&token=${ token }` }); + const subscribeRoute = this.queryParams.details === 'true' ? 'subscribe/details' : 'subscribe'; + + const seats = Users.getActiveLocalUserCount(); + + return API.v1.success({ + url: `${ baseUrl }/apps/${ this.queryParams.appId }/${ + this.queryParams.purchaseType === 'buy' ? this.queryParams.purchaseType : subscribeRoute + }?workspaceId=${ workspaceId }&token=${ token }&seats=${ seats }`, + }); } const apps = manager.get().map((prl) => { @@ -115,39 +149,72 @@ export class AppsRestApi { }, post() { let buff; + let marketplaceInfo; if (this.bodyParams.url) { if (settings.get('Apps_Framework_Development_Mode') !== true) { return API.v1.failure({ error: 'Installation from url is disabled.' }); } - const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'binary' } }); + let result; + try { + result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: null } }); + } catch (e) { + orchestrator.getRocketChatLogger().error('Error getting the app from url:', e.response.data); + return API.v1.internalError(); + } if (result.statusCode !== 200 || !result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' }); } - buff = Buffer.from(result.content, 'binary'); + buff = result.content; } else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { const baseUrl = orchestrator.getMarketplaceUrl(); - const headers = {}; - const token = getWorkspaceAccessToken(true, 'marketplace:download', false); + const headers = getDefaultHeaders(); - const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }?token=${ token }`, { - headers, - npmRequestOptions: { encoding: 'binary' }, + const downloadPromise = new Promise((resolve, reject) => { + const token = getWorkspaceAccessToken(true, 'marketplace:download', false); + + HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }?token=${ token }`, { + headers, + npmRequestOptions: { encoding: null }, + }, (error, result) => { + if (error) { reject(error); } + + resolve(result); + }); }); - if (result.statusCode !== 200) { - return API.v1.failure(); - } + const marketplacePromise = new Promise((resolve, reject) => { + const token = getWorkspaceAccessToken(); - if (!result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { - return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' }); - } + HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }?appVersion=${ this.bodyParams.version }`, { + headers: { + Authorization: `Bearer ${ token }`, + ...headers, + }, + }, (error, result) => { + if (error) { reject(error); } + + resolve(result); + }); + }); + + + try { + const [downloadResult, marketplaceResult] = Promise.await(Promise.all([downloadPromise, marketplacePromise])); + + if (!downloadResult.headers['content-type'] || downloadResult.headers['content-type'] !== 'application/zip') { + throw new Error('Invalid url. It doesn\'t exist or is not "application/zip".'); + } - buff = Buffer.from(result.content, 'binary'); + buff = downloadResult.content; + marketplaceInfo = marketplaceResult.data[0]; + } catch (err) { + return API.v1.failure(err.message); + } } else { if (settings.get('Apps_Framework_Development_Mode') !== true) { return API.v1.failure({ error: 'Direct installation of an App is disabled.' }); @@ -160,20 +227,27 @@ export class AppsRestApi { return API.v1.failure({ error: 'Failed to get a file to install for the App. ' }); } - const aff = Promise.await(manager.add(buff.toString('base64'), false)); + const aff = Promise.await(manager.add(buff.toString('base64'), false, marketplaceInfo)); const info = aff.getAppInfo(); - // If there are compiler errors, there won't be an App to get the status of - if (aff.getApp()) { - info.status = aff.getApp().getStatus(); - } else { - info.status = 'compiler_error'; + if (aff.hasStorageError()) { + return API.v1.failure({ status: 'storage_error', messages: [aff.getStorageError()] }); } + if (aff.getCompilerErrors().length) { + return API.v1.failure({ status: 'compiler_error', messages: aff.getCompilerErrors() }); + } + + if (aff.getLicenseValidationResult().hasErrors) { + return API.v1.failure({ status: 'license_error', messages: aff.getLicenseValidationResult().getErrors() }); + } + + info.status = aff.getApp().getStatus(); + return API.v1.success({ app: info, implemented: aff.getImplementedInferfaces(), - compilerErrors: aff.getCompilerErrors(), + licenseValidation: aff.getLicenseValidationResult(), }); }, }); @@ -199,11 +273,18 @@ export class AppsRestApi { headers.Authorization = `Bearer ${ token }`; } - const result = HTTP.get(`${ baseUrl }/v1/bundles/${ this.urlParams.id }/apps`, { - headers, - }); + let result; + try { + result = HTTP.get(`${ baseUrl }/v1/bundles/${ this.urlParams.id }/apps`, { + headers, + }); + } catch (e) { + orchestrator.getRocketChatLogger().error('Error getting the Bundle\'s Apps from the Marketplace:', e.response.data); + return API.v1.internalError(); + } - if (result.statusCode !== 200 || result.data.length === 0) { + if (!result || result.statusCode !== 200 || result.data.length === 0) { + orchestrator.getRocketChatLogger().error('Error getting the Bundle\'s Apps from the Marketplace:', result.data); return API.v1.failure(); } @@ -211,22 +292,42 @@ export class AppsRestApi { }, }); + const handleError = (message, e) => { + orchestrator.getRocketChatLogger().error(message, e.response.data); + + if (e.response.statusCode >= 500 && e.response.statusCode <= 599) { + return API.v1.internalError(); + } + + if (e.response.statusCode === 404) { + return API.v1.notFound(); + } + + return API.v1.failure(); + }; + this.api.addRoute(':id', { authRequired: true, permissionsRequired: ['manage-apps'] }, { get() { if (this.queryParams.marketplace && this.queryParams.version) { const baseUrl = orchestrator.getMarketplaceUrl(); - const headers = {}; + const headers = {}; // DO NOT ATTACH THE FRAMEWORK/ENGINE VERSION HERE. const token = getWorkspaceAccessToken(); if (token) { headers.Authorization = `Bearer ${ token }`; } - const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.urlParams.id }?appVersion=${ this.queryParams.version }`, { - headers, - }); + let result; + try { + result = HTTP.get(`${ baseUrl }/v1/apps/${ this.urlParams.id }?appVersion=${ this.queryParams.version }`, { + headers, + }); + } catch (e) { + return handleError('Error getting the App information from the Marketplace:', e); + } - if (result.statusCode !== 200 || result.data.length === 0) { + if (!result || result.statusCode !== 200 || result.data.length === 0) { + orchestrator.getRocketChatLogger().error('Error getting the App information from the Marketplace:', result.data); return API.v1.failure(); } @@ -236,17 +337,23 @@ export class AppsRestApi { if (this.queryParams.marketplace && this.queryParams.update && this.queryParams.appVersion) { const baseUrl = orchestrator.getMarketplaceUrl(); - const headers = {}; + const headers = getDefaultHeaders(); const token = getWorkspaceAccessToken(); if (token) { headers.Authorization = `Bearer ${ token }`; } - const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.urlParams.id }/latest?frameworkVersion=${ Info.marketplaceApiVersion }`, { - headers, - }); + let result; + try { + result = HTTP.get(`${ baseUrl }/v1/apps/${ this.urlParams.id }/latest?frameworkVersion=${ Info.marketplaceApiVersion }`, { + headers, + }); + } catch (e) { + return handleError('Error getting the App update info from the Marketplace:', e); + } if (result.statusCode !== 200 || result.data.length === 0) { + orchestrator.getRocketChatLogger().error('Error getting the App update info from the Marketplace:', result.data); return API.v1.failure(); } @@ -257,15 +364,19 @@ export class AppsRestApi { if (prl) { const info = prl.getInfo(); - info.status = prl.getStatus(); - return API.v1.success({ app: info }); + return API.v1.success({ + app: { + ...info, + status: prl.getStatus(), + licenseValidation: prl.getLatestLicenseValidationResult(), + }, + }); } + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); }, post() { - // TODO: Verify permissions - let buff; if (this.bodyParams.url) { @@ -273,28 +384,35 @@ export class AppsRestApi { return API.v1.failure({ error: 'Updating an App from a url is disabled.' }); } - const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'binary' } }); + const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: null } }); if (result.statusCode !== 200 || !result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' }); } - buff = Buffer.from(result.content, 'binary'); + buff = result.content; } else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) { const baseUrl = orchestrator.getMarketplaceUrl(); - const headers = {}; + const headers = getDefaultHeaders(); const token = getWorkspaceAccessToken(); if (token) { headers.Authorization = `Bearer ${ token }`; } - const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }`, { - headers, - npmRequestOptions: { encoding: 'binary' }, - }); + let result; + try { + result = HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }`, { + headers, + npmRequestOptions: { encoding: null }, + }); + } catch (e) { + orchestrator.getRocketChatLogger().error('Error getting the App from the Marketplace:', e.response.data); + return API.v1.internalError(); + } if (result.statusCode !== 200) { + orchestrator.getRocketChatLogger().error('Error getting the App from the Marketplace:', result.data); return API.v1.failure(); } @@ -302,7 +420,7 @@ export class AppsRestApi { return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' }); } - buff = Buffer.from(result.content, 'binary'); + buff = result.content; } else { if (settings.get('Apps_Framework_Development_Mode') !== true) { return API.v1.failure({ error: 'Direct updating of an App is disabled.' }); @@ -346,6 +464,39 @@ export class AppsRestApi { }, }); + this.api.addRoute(':id/sync', { authRequired: true, permissionsRequired: ['manage-apps'] }, { + post() { + const baseUrl = orchestrator.getMarketplaceUrl(); + + const headers = getDefaultHeaders(); + const token = getWorkspaceAccessToken(); + if (token) { + headers.Authorization = `Bearer ${ token }`; + } + + const [workspaceIdSetting] = Settings.findById('Cloud_Workspace_Id').fetch(); + + let result; + try { + result = HTTP.get(`${ baseUrl }/v1/workspaces/${ workspaceIdSetting.value }/apps/${ this.urlParams.id }`, { + headers, + }); + } catch (e) { + orchestrator.getRocketChatLogger().error('Error syncing the App from the Marketplace:', e.response.data); + return API.v1.internalError(); + } + + if (result.statusCode !== 200) { + orchestrator.getRocketChatLogger().error('Error syncing the App from the Marketplace:', result.data); + return API.v1.failure(); + } + + Promise.await(Apps.updateAppsMarketplaceInfo([result.data])); + + return API.v1.success({ app: result.data }); + }, + }); + this.api.addRoute(':id/icon', { authRequired: true, permissionsRequired: ['manage-apps'] }, { get() { const prl = manager.getOneById(this.urlParams.id); @@ -503,12 +654,13 @@ export class AppsRestApi { const prl = manager.getOneById(this.urlParams.id); - if (prl) { - const result = Promise.await(manager.changeStatus(prl.getID(), this.bodyParams.status)); - - return API.v1.success({ status: result.getStatus() }); + if (!prl) { + return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); } - return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`); + + const result = Promise.await(manager.changeStatus(prl.getID(), this.bodyParams.status)); + + return API.v1.success({ status: result.getStatus() }); }, }); } diff --git a/app/apps/server/communication/websockets.js b/app/apps/server/communication/websockets.js index 94b9c22765a7..857158ddd3bb 100644 --- a/app/apps/server/communication/websockets.js +++ b/app/apps/server/communication/websockets.js @@ -1,5 +1,5 @@ import { Meteor } from 'meteor/meteor'; -import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; +import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; export const AppEvents = Object.freeze({ APP_ADDED: 'app/added', @@ -20,11 +20,12 @@ export class AppServerListener { this.clientStreamer = clientStreamer; this.received = received; - this.engineStreamer.on(AppEvents.APP_ADDED, this.onAppAdded.bind(this)); this.engineStreamer.on(AppEvents.APP_STATUS_CHANGE, this.onAppStatusUpdated.bind(this)); - this.engineStreamer.on(AppEvents.APP_SETTING_UPDATED, this.onAppSettingUpdated.bind(this)); this.engineStreamer.on(AppEvents.APP_REMOVED, this.onAppRemoved.bind(this)); this.engineStreamer.on(AppEvents.APP_UPDATED, this.onAppUpdated.bind(this)); + this.engineStreamer.on(AppEvents.APP_ADDED, this.onAppAdded.bind(this)); + + this.engineStreamer.on(AppEvents.APP_SETTING_UPDATED, this.onAppSettingUpdated.bind(this)); this.engineStreamer.on(AppEvents.COMMAND_ADDED, this.onCommandAdded.bind(this)); this.engineStreamer.on(AppEvents.COMMAND_DISABLED, this.onCommandDisabled.bind(this)); this.engineStreamer.on(AppEvents.COMMAND_UPDATED, this.onCommandUpdated.bind(this)); @@ -36,21 +37,27 @@ export class AppServerListener { this.clientStreamer.emit(AppEvents.APP_ADDED, appId); } + async onAppStatusUpdated({ appId, status }) { + const app = this.orch.getManager().getOneById(appId); + + if (app.getStatus() === status) { + return; + } + this.received.set(`${ AppEvents.APP_STATUS_CHANGE }_${ appId }`, { appId, status, when: new Date() }); if (AppStatusUtils.isEnabled(status)) { - await this.orch.getManager().enable(appId); + await this.orch.getManager().enable(appId).catch(console.error); this.clientStreamer.emit(AppEvents.APP_STATUS_CHANGE, { appId, status }); } else if (AppStatusUtils.isDisabled(status)) { - await this.orch.getManager().disable(appId, AppStatus.MANUALLY_DISABLED === status); + await this.orch.getManager().disable(appId, status, true).catch(console.error); this.clientStreamer.emit(AppEvents.APP_STATUS_CHANGE, { appId, status }); } } async onAppSettingUpdated({ appId, setting }) { this.received.set(`${ AppEvents.APP_SETTING_UPDATED }_${ appId }_${ setting.id }`, { appId, setting, when: new Date() }); - await this.orch.getManager().getSettingsManager().updateAppSetting(appId, setting); this.clientStreamer.emit(AppEvents.APP_SETTING_UPDATED, { appId }); } @@ -65,6 +72,12 @@ export class AppServerListener { } async onAppRemoved(appId) { + const app = this.orch.getManager().getOneById(appId); + + if (!app) { + return; + } + await this.orch.getManager().remove(appId); this.clientStreamer.emit(AppEvents.APP_REMOVED, appId); } diff --git a/app/apps/server/cron.js b/app/apps/server/cron.js new file mode 100644 index 000000000000..bbd40f7c0016 --- /dev/null +++ b/app/apps/server/cron.js @@ -0,0 +1,116 @@ +import { Meteor } from 'meteor/meteor'; +import { HTTP } from 'meteor/http'; +import { SyncedCron } from 'meteor/littledata:synced-cron'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; + +import { Apps } from './orchestrator'; +import { getWorkspaceAccessToken } from '../../cloud/server'; +import { Settings, Users, Roles } from '../../models/server'; + + +const notifyAdminsAboutInvalidApps = Meteor.bindEnvironment(function _notifyAdminsAboutInvalidApps(apps) { + const hasInvalidApps = !!apps.find((app) => app.getLatestLicenseValidationResult().hasErrors); + + if (!hasInvalidApps) { + return apps; + } + + const id = 'someAppInInvalidState'; + const title = 'Warning'; + const text = 'There is one or more apps in an invalid state. Click here to review.'; + const rocketCatMessage = 'There is one or more apps in an invalid state. Go to Administration > Apps to review.'; + const link = '/admin/apps'; + + Roles.findUsersInRole('admin').forEach((adminUser) => { + Users.removeBannerById(adminUser._id, { id }); + + try { + Meteor.runAsUser(adminUser._id, () => Meteor.call('createDirectMessage', 'rocket.cat')); + + Meteor.runAsUser('rocket.cat', () => Meteor.call('sendMessage', { + msg: `*${ TAPi18n.__(title, adminUser.language) }*\n${ TAPi18n.__(rocketCatMessage, adminUser.language) }`, + rid: [adminUser._id, 'rocket.cat'].sort().join(''), + })); + } catch (e) { + console.error(e); + } + + Users.addBannerById(adminUser._id, { + id, + priority: 10, + title, + text, + modifiers: ['danger'], + link, + }); + }); + + return apps; +}); + +const notifyAdminsAboutRenewedApps = Meteor.bindEnvironment(function _notifyAdminsAboutRenewedApps(apps) { + const renewedApps = apps.filter((app) => app.getStatus() === AppStatus.DISABLED && app.getPreviousStatus() === AppStatus.INVALID_LICENSE_DISABLED); + + if (renewedApps.length === 0) { + return; + } + + const rocketCatMessage = 'There is one or more disabled apps with valid licenses. Go to Administration > Apps to review.'; + + Roles.findUsersInRole('admin').forEach((adminUser) => { + try { + Meteor.runAsUser(adminUser._id, () => Meteor.call('createDirectMessage', 'rocket.cat')); + + Meteor.runAsUser('rocket.cat', () => Meteor.call('sendMessage', { + msg: `${ TAPi18n.__(rocketCatMessage, adminUser.language) }`, + rid: [adminUser._id, 'rocket.cat'].sort().join(''), + })); + } catch (e) { + console.error(e); + } + }); +}); + +export const appsUpdateMarketplaceInfo = Meteor.bindEnvironment(function _appsUpdateMarketplaceInfo() { + const token = getWorkspaceAccessToken(); + const baseUrl = Apps.getMarketplaceUrl(); + const [workspaceIdSetting] = Settings.findById('Cloud_Workspace_Id').fetch(); + + const currentSeats = Users.getActiveLocalUserCount(); + + const fullUrl = `${ baseUrl }/v1/workspaces/${ workspaceIdSetting.value }/apps?seats=${ currentSeats }`; + const options = { + headers: { + Authorization: `Bearer ${ token }`, + }, + }; + + let data = []; + + try { + const result = HTTP.get(fullUrl, options); + + if (Array.isArray(result.data)) { + data = result.data; + } + } catch (err) { + Apps.debugLog(err); + } + + Promise.await( + Apps.updateAppsMarketplaceInfo(data) + .then(notifyAdminsAboutInvalidApps) + .then(notifyAdminsAboutRenewedApps) + ); +}); + +SyncedCron.add({ + name: 'Apps-Engine:check', + schedule: (parser) => parser.text('at 4:00 pm'), + job() { + appsUpdateMarketplaceInfo(); + }, +}); + +SyncedCron.start(); diff --git a/app/apps/server/index.js b/app/apps/server/index.js index 0d8b925c2207..aa24a2d78926 100644 --- a/app/apps/server/index.js +++ b/app/apps/server/index.js @@ -1 +1,3 @@ +import './cron'; + export { Apps } from './orchestrator'; diff --git a/app/apps/server/orchestrator.js b/app/apps/server/orchestrator.js index 60b0f874de61..427fae9f5006 100644 --- a/app/apps/server/orchestrator.js +++ b/app/apps/server/orchestrator.js @@ -7,14 +7,14 @@ import { AppMessagesConverter, AppRoomsConverter, AppSettingsConverter, AppUsers import { AppRealStorage, AppRealLogsStorage } from './storage'; import { settings } from '../../settings'; import { Permissions, AppsLogsModel, AppsModel, AppsPersistenceModel } from '../../models'; +import { Logger } from '../../logger'; export let Apps; class AppServerOrchestrator { constructor() { - if (Permissions) { - Permissions.createOrUpdate('manage-apps', ['admin']); - } + this._rocketchatLogger = new Logger('Rocket.Chat Apps'); + Permissions.createOrUpdate('manage-apps', ['admin']); this._marketplaceUrl = 'https://marketplace.rocket.chat'; @@ -84,10 +84,14 @@ class AppServerOrchestrator { return settings.get('Apps_Framework_Development_Mode'); } - debugLog() { + getRocketChatLogger() { + return this._rocketchatLogger; + } + + debugLog(...args) { if (this.isDebugging()) { // eslint-disable-next-line - console.log(...arguments); + console.log(...args); } } @@ -95,29 +99,38 @@ class AppServerOrchestrator { return this._marketplaceUrl; } - load() { + async load() { // Don't try to load it again if it has // already been loaded if (this.isLoaded()) { return; } - this._manager.load() + return this._manager.load() .then((affs) => console.log(`Loaded the Apps Framework and loaded a total of ${ affs.length } Apps!`)) .catch((err) => console.warn('Failed to load the Apps Framework and Apps!', err)); } - unload() { + async unload() { // Don't try to unload it if it's already been // unlaoded or wasn't unloaded to start with if (!this.isLoaded()) { return; } - this._manager.unload() + return this._manager.unload() .then(() => console.log('Unloaded the Apps Framework.')) .catch((err) => console.warn('Failed to unload the Apps Framework!', err)); } + + async updateAppsMarketplaceInfo(apps = []) { + if (!this.isLoaded()) { + return; + } + + return this._manager.updateAppsMarketplaceInfo(apps) + .then(() => this._manager.get()); + } } settings.addGroup('General', function() { diff --git a/app/apps/server/storage/storage.js b/app/apps/server/storage/storage.js index 4b8c08eb572d..8fc9c79b705f 100644 --- a/app/apps/server/storage/storage.js +++ b/app/apps/server/storage/storage.js @@ -44,11 +44,7 @@ export class AppRealStorage extends AppStorage { return reject(e); } - if (doc) { - resolve(doc); - } else { - reject(new Error(`No App found by the id: ${ id }`)); - } + resolve(doc); }); } @@ -74,12 +70,11 @@ export class AppRealStorage extends AppStorage { return new Promise((resolve, reject) => { try { this.db.update({ id: item.id }, item); + resolve(item.id); } catch (e) { return reject(e); } - - this.retrieveOne(item.id).then((updated) => resolve(updated)).catch((err) => reject(err)); - }); + }).then(this.retrieveOne.bind(this)); } remove(id) { diff --git a/app/authorization/client/stylesheets/permissions.css b/app/authorization/client/stylesheets/permissions.css index 9bf8e0f147d1..1561d2ebf536 100644 --- a/app/authorization/client/stylesheets/permissions.css +++ b/app/authorization/client/stylesheets/permissions.css @@ -1,4 +1,9 @@ .permissions-manager { + display: flex; + flex-direction: column; + + height: 100%; + &.page-container { padding-bottom: 0 !important; } diff --git a/app/authorization/client/views/permissionsRole.html b/app/authorization/client/views/permissionsRole.html index 403d69ec50fc..a8a5ac92c312 100644 --- a/app/authorization/client/views/permissionsRole.html +++ b/app/authorization/client/views/permissionsRole.html @@ -1,36 +1,46 @@ {{#if hasPermission}} - {{_ "Back_to_permissions"}} - - {{#with role}} - - {{_ "Role"}} : - {{#if editing}} - {{_id}} - {{else}} - - {{/if}} - - {{_ "Description"}} : - - - {{_ "Scope"}} : - - {{_ "Global"}} - {{_ "Rooms"}} - + + - - {{_ "Users must use Two Factor Authentication"}} : - + + {{_ "Role"}} + {{#if editing}} + + {{else}} + + {{/if}} + + + + + {{_ "Description"}} + + + + + {{_ "Scope"}} + + + {{_ "Global"}} + {{_ "Rooms"}} + + {{> icon block="rc-select__arrow" icon="arrow-down" }} + + + + {{_ "Users must use Two Factor Authentication"}} : + + - + {{#if editable}} - {{_ "Delete"}} + {{_ "Delete"}} {{/if}} - {{_ "Save"}} + {{_ "Save"}} + {{_ "Back_to_permissions"}} {{/with}} @@ -38,25 +48,28 @@ {{_ "Users_in_role"}} {{#if $eq role.scope 'Subscriptions'}} - {{_ "Choose_a_room"}} - {{> inputAutocomplete settings=autocompleteChannelSettings name="room" class="search" placeholder=(_ "Enter_a_room_name") autocomplete="off"}} + + {{_ "Choose_a_room"}} + {{> inputAutocomplete settings=autocompleteChannelSettings name="room" class="search autocomplete rc-input__element" placeholder=(_ "Enter_a_room_name") autocomplete="off"}} + {{/if}} {{#if $or ($eq role.scope 'Users') searchRoom}} - {{_ "Add_user"}} - {{> inputAutocomplete settings=autocompleteUsernameSettings name="username" class="search" placeholder=(_ "Enter_a_username") autocomplete="off"}} - {{_ "Add"}} + + {{_ "Add_user"}} + {{> inputAutocomplete settings=autocompleteUsernameSettings name="username" class="search autocomplete rc-input__element" placeholder=(_ "Enter_a_username") autocomplete="off"}} + + {{_ "Add"}} - - + + {{#table fixed='true' onItemClick=onTableItemClick onScroll=onTableScroll onResize=onTableResize}} - - {{_ "Name"}} - {{_ "Username"}} - {{_ "Email"}} - + {{_ "Name"}} + {{_ "Username"}} + {{_ "Email"}} + @@ -67,21 +80,23 @@ {{_ "Users_in_role"}} {{/unless}} {{#each userInRole}} - - - {{> avatar username=username}} + + + {{> avatar username=username}} + + {{name}} + - {{name}} - {{username}} - {{emailAddress}} + {{username}} + {{emailAddress}} {{/each}} - + {{/table}} {{#if hasMore}} - {{_ "Load_more"}} + {{_ "Load_more"}} {{/if}} {{/if}} diff --git a/app/authorization/client/views/permissionsRole.js b/app/authorization/client/views/permissionsRole.js index e76cac546cf7..1788e47a2dd2 100644 --- a/app/authorization/client/views/permissionsRole.js +++ b/app/authorization/client/views/permissionsRole.js @@ -1,4 +1,5 @@ import { Meteor } from 'meteor/meteor'; +import { ReactiveDict } from 'meteor/reactive-dict'; import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; @@ -11,6 +12,32 @@ import { Roles } from '../../../models'; import { hasAllPermission } from '../hasPermission'; import { modal } from '../../../ui-utils/client/lib/modal'; import { SideNav } from '../../../ui-utils/client/lib/SideNav'; +import { APIClient } from '../../../utils/client'; +import { call } from '../../../ui-utils/client'; + +const PAGE_SIZE = 50; + +const loadUsers = async (instance) => { + const offset = instance.state.get('offset'); + + const rid = instance.searchRoom.get(); + + const params = { + role: FlowRouter.getParam('name'), + offset, + count: PAGE_SIZE, + ...rid && { roomId: rid }, + }; + + instance.state.set('loading', true); + const { users } = await APIClient.v1.get('roles.getUsersInRole', params); + + instance.usersInRole.set(instance.usersInRole.curValue.concat(users)); + instance.state.set({ + loading: false, + hasMore: users.length === PAGE_SIZE, + }); +}; Template.permissionsRole.helpers({ role() { @@ -46,19 +73,16 @@ Template.permissionsRole.helpers({ }, hasUsers() { - return Template.instance().usersInRole.get() && Template.instance().usersInRole.get().count() > 0; + return Template.instance().usersInRole.get().length > 0; }, hasMore() { - const instance = Template.instance(); - return instance.limit && instance.limit.get() <= instance.usersInRole.get().count(); + return Template.instance().state.get('hasMore'); }, isLoading() { const instance = Template.instance(); - if (!instance.ready || !instance.ready.get()) { - return 'btn-loading'; - } + return (!instance.subscription.ready() || instance.state.get('loading')) && 'btn-loading'; }, searchRoom() { @@ -100,7 +124,7 @@ Template.permissionsRole.helpers({ noMatchTemplate: Template.userSearchEmpty, matchAll: true, filter: { - exceptions: instance.usersInRole.get() && instance.usersInRole.get().fetch(), + exceptions: instance.usersInRole.get(), }, selector(match) { return { @@ -127,19 +151,15 @@ Template.permissionsRole.events({ cancelButtonText: t('Cancel'), closeOnConfirm: false, html: false, - }, () => { - Meteor.call('authorization:removeUserFromRole', FlowRouter.getParam('name'), this.username, instance.searchRoom.get(), function(error/* , result*/) { - if (error) { - return handleError(error); - } - - modal.open({ - title: t('Removed'), - text: t('User_removed'), - type: 'success', - timer: 1000, - showConfirmButton: false, - }); + }, async () => { + await call('authorization:removeUserFromRole', FlowRouter.getParam('name'), this.username, instance.searchRoom.get()); + instance.usersInRole.set(instance.usersInRole.curValue.filter((user) => user.username !== this.username)); + modal.open({ + title: t('Removed'), + text: t('User_removed'), + type: 'success', + timer: 1000, + showConfirmButton: false, }); }); }, @@ -176,7 +196,7 @@ Template.permissionsRole.events({ }); }, - 'submit #form-users'(e, instance) { + async 'submit #form-users'(e, instance) { e.preventDefault(); if (e.currentTarget.elements.username.value.trim() === '') { return toastr.error(t('Please_fill_a_username')); @@ -184,15 +204,18 @@ Template.permissionsRole.events({ const oldBtnValue = e.currentTarget.elements.add.value; e.currentTarget.elements.add.value = t('Saving'); - Meteor.call('authorization:addUserToRole', FlowRouter.getParam('name'), e.currentTarget.elements.username.value, instance.searchRoom.get(), (error/* , result*/) => { - e.currentTarget.elements.add.value = oldBtnValue; - if (error) { - return handleError(error); - } - instance.subscribe('usersInRole', FlowRouter.getParam('name'), instance.searchRoom.get()); + try { + await call('authorization:addUserToRole', FlowRouter.getParam('name'), e.currentTarget.elements.username.value, instance.searchRoom.get()); + instance.usersInRole.set([]); + instance.state.set({ + offset: 0, + cache: Date.now(), + }); toastr.success(t('User_added')); e.currentTarget.reset(); - }); + } finally { + e.currentTarget.elements.add.value = oldBtnValue; + } }, 'submit #form-search-room'(e) { @@ -215,9 +238,7 @@ Template.permissionsRole.events({ }, 'click .load-more'(e, t) { - e.preventDefault(); - e.stopPropagation(); - t.limit.set(t.limit.get() + 50); + t.state.set('offset', t.state.get('offset') + PAGE_SIZE); }, 'autocompleteselect input[name=room]'(event, template, doc) { @@ -225,33 +246,32 @@ Template.permissionsRole.events({ }, }); -Template.permissionsRole.onCreated(function() { +Template.permissionsRole.onCreated(async function() { + this.state = new ReactiveDict({ + offset: 0, + loading: false, + hasMore: true, + cache: 0, + }); this.searchRoom = new ReactiveVar(); this.searchUsername = new ReactiveVar(); - this.usersInRole = new ReactiveVar(); - this.limit = new ReactiveVar(50); - this.ready = new ReactiveVar(true); - this.subscribe('roles', FlowRouter.getParam('name')); - - this.autorun(() => { - if (this.searchRoom.get()) { - this.subscribe('roomSubscriptionsByRole', this.searchRoom.get(), FlowRouter.getParam('name')); - } + this.usersInRole = new ReactiveVar([]); - const limit = this.limit.get(); + this.subscription = this.subscribe('roles', FlowRouter.getParam('name')); +}); - const subscription = this.subscribe('usersInRole', FlowRouter.getParam('name'), this.searchRoom.get(), limit); - this.ready.set(subscription.ready()); +Template.permissionsRole.onRendered(function() { + this.autorun(() => { + this.searchRoom.get(); + this.usersInRole.set([]); + this.state.set({ offset: 0 }); + }); - this.usersInRole.set(Roles.findUsersInRole(FlowRouter.getParam('name'), this.searchRoom.get(), { - sort: { - username: 1, - }, - })); + this.autorun(() => { + this.state.get('cache'); + loadUsers(this); }); -}); -Template.permissionsRole.onRendered(() => { Tracker.afterFlush(() => { SideNav.setFlex('adminFlex'); SideNav.openFlex(); diff --git a/app/authorization/server/functions/canSendMessage.js b/app/authorization/server/functions/canSendMessage.js index 7ca6a41d3646..fbae305e07ac 100644 --- a/app/authorization/server/functions/canSendMessage.js +++ b/app/authorization/server/functions/canSendMessage.js @@ -1,44 +1,38 @@ -import { Meteor } from 'meteor/meteor'; -import { TAPi18n } from 'meteor/tap:i18n'; -import { Random } from 'meteor/random'; - -import { canAccessRoom } from './canAccessRoom'; -import { hasPermission } from './hasPermission'; -import { Notifications } from '../../../notifications'; -import { Rooms, Subscriptions } from '../../../models'; - +import { canAccessRoomAsync } from './canAccessRoom'; +import { hasPermissionAsync } from './hasPermission'; +import { Subscriptions, Rooms } from '../../../models/server/raw'; + +const subscriptionOptions = { + projection: { + blocked: 1, + blocker: 1, + }, +}; -export const canSendMessage = (rid, { uid, username }, extraData) => { - const room = Rooms.findOneById(rid); +export const canSendMessageAsync = async (rid, { uid, username }, extraData) => { + const room = await Rooms.findOneById(rid); - if (!canAccessRoom.call(this, room, { _id: uid, username }, extraData)) { - throw new Meteor.Error('error-not-allowed'); + if (!await canAccessRoomAsync(room, { _id: uid, username }, extraData)) { + throw new Error('error-not-allowed'); } - const subscription = Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (subscription && (subscription.blocked || subscription.blocker)) { - throw new Meteor.Error('room_is_blocked'); + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, uid, subscriptionOptions); + if (subscription.blocked || subscription.blocker) { + throw new Error('room_is_blocked'); } - if (room.ro === true) { - if (!hasPermission(Meteor.userId(), 'post-readonly', room._id)) { - // Unless the user was manually unmuted - if (!(room.unmuted || []).includes(username)) { - Notifications.notifyUser(Meteor.userId(), 'message', { - _id: Random.id(), - rid: room._id, - ts: new Date(), - msg: TAPi18n.__('room_is_read_only'), - }); - - throw new Meteor.Error('You can\'t send messages because the room is readonly.'); - } + if (room.ro === true && !await hasPermissionAsync(uid, 'post-readonly', rid)) { + // Unless the user was manually unmuted + if (!(room.unmuted || []).includes(username)) { + throw new Error('You can\'t send messages because the room is readonly.'); } } if ((room.muted || []).includes(username)) { - throw new Meteor.Error('You_have_been_muted'); + throw new Error('You_have_been_muted'); } return room; }; + +export const canSendMessage = (rid, { uid, username }, extraData) => Promise.await(canSendMessageAsync(rid, { uid, username }, extraData)); diff --git a/app/authorization/server/methods/addUserToRole.js b/app/authorization/server/methods/addUserToRole.js index 575fad009783..b9d8302bc0b3 100644 --- a/app/authorization/server/methods/addUserToRole.js +++ b/app/authorization/server/methods/addUserToRole.js @@ -35,7 +35,14 @@ Meteor.methods({ }); if (!user || !user._id) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { + throw new Meteor.Error('error-user-not-found', 'User not found', { + method: 'authorization:addUserToRole', + }); + } + + // verify if user can be added to given scope + if (scope && !Roles.canAddUserToRole(user._id, roleName, scope)) { + throw new Meteor.Error('error-invalid-user', 'User is not part of given room', { method: 'authorization:addUserToRole', }); } diff --git a/app/authorization/server/publications/permissions/emitter.js b/app/authorization/server/publications/permissions/emitter.js new file mode 100644 index 000000000000..e67c53d8fefc --- /dev/null +++ b/app/authorization/server/publications/permissions/emitter.js @@ -0,0 +1,24 @@ +import { Notifications } from '../../../../notifications'; +import Permissions from '../../../../models/server/models/Permissions'; + +Permissions.on('change', ({ clientAction, id, data, diff }) => { + if (diff && Object.keys(diff).length === 1 && diff._updatedAt) { // avoid useless changes + return; + } + switch (clientAction) { + case 'updated': + case 'inserted': + data = data || Permissions.findOneById(id); + break; + + case 'removed': + data = { _id: id }; + break; + } + + Notifications.notifyLoggedInThisInstance( + 'permissions-changed', + clientAction, + data + ); +}); diff --git a/app/authorization/server/publications/permissions.js b/app/authorization/server/publications/permissions/index.js similarity index 52% rename from app/authorization/server/publications/permissions.js rename to app/authorization/server/publications/permissions/index.js index b99a9bbd5ae2..45f4f9d01c1a 100644 --- a/app/authorization/server/publications/permissions.js +++ b/app/authorization/server/publications/permissions/index.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; -import Permissions from '../../../models/server/models/Permissions'; -import { Notifications } from '../../../notifications'; +import Permissions from '../../../../models/server/models/Permissions'; +import './emitter'; Meteor.methods({ 'permissions/get'(updatedAt) { @@ -20,18 +20,3 @@ Meteor.methods({ return records; }, }); - -Permissions.on('change', ({ clientAction, id, data }) => { - switch (clientAction) { - case 'updated': - case 'inserted': - data = data || Permissions.findOneById(id); - break; - - case 'removed': - data = { _id: id }; - break; - } - - Notifications.notifyLoggedInThisInstance('permissions-changed', clientAction, data); -}); diff --git a/app/authorization/server/publications/usersInRole.js b/app/authorization/server/publications/usersInRole.js index 5d52c862f765..f7eb22146e45 100644 --- a/app/authorization/server/publications/usersInRole.js +++ b/app/authorization/server/publications/usersInRole.js @@ -4,6 +4,8 @@ import { hasPermission } from '../functions/hasPermission'; import { getUsersInRole } from '../functions/getUsersInRole'; Meteor.publish('usersInRole', function(roleName, scope, limit = 50) { + console.warn('The publication "usersInRole" is deprecated and will be removed after version v2.0.0'); + if (!this.userId) { return this.ready(); } diff --git a/app/callbacks/lib/callbacks.js b/app/callbacks/lib/callbacks.js index 589764076924..193b6be06ed6 100644 --- a/app/callbacks/lib/callbacks.js +++ b/app/callbacks/lib/callbacks.js @@ -2,6 +2,12 @@ import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; import _ from 'underscore'; +let timed = false; + +if (Meteor.isClient) { + const { getConfig } = require('../../ui-utils/client/config'); + timed = [getConfig('debug'), getConfig('timed-callbacks')].includes('true'); +} /* * Callback hooks provide an easy way to add extra steps to common operations. * @namespace RocketChat.callbacks @@ -9,15 +15,48 @@ import _ from 'underscore'; export const callbacks = {}; -if (Meteor.isServer) { - callbacks.showTime = true; - callbacks.showTotalTime = true; -} else { - callbacks.showTime = false; - callbacks.showTotalTime = false; -} +const wrapCallback = (callback) => (...args) => { + const time = Date.now(); + const result = callback(...args); + const currentTime = Date.now() - time; + let stack = callback.stack + && typeof callback.stack.split === 'function' + && callback.stack.split('\n'); + stack = stack && stack[2] && (stack[2].match(/\(.+\)/) || [])[0]; + console.log(String(currentTime), callback.hook, callback.id, stack); + return result; +}; + +const wrapRun = (hook, fn) => (...args) => { + const time = Date.now(); + const ret = fn(...args); + const totalTime = Date.now() - time; + console.log(`${ hook }:`, totalTime); + return ret; +}; + +const handleResult = (fn) => (result, constant) => { + const callbackResult = callbacks.runItem({ hook: fn.hook, callback: fn, result, constant }); + return typeof callbackResult === 'undefined' ? result : callbackResult; +}; +const identity = (e) => e; +const pipe = (f, g) => (e, ...constants) => g(f(e, ...constants), ...constants); +const createCallback = (hook, callbacks) => callbacks.map(handleResult).reduce(pipe, identity); + +const createCallbackTimed = (hook, callbacks) => + wrapRun(hook, + callbacks + .map(wrapCallback) + .map(handleResult) + .reduce(pipe, identity) + ); + +const create = (hook, cbs) => + (timed ? createCallbackTimed(hook, cbs) : createCallback(hook, cbs)); +const combinedCallbacks = new Map(); +this.combinedCallbacks = combinedCallbacks; /* * Callback priorities */ @@ -36,26 +75,24 @@ const getHooks = (hookName) => callbacks[hookName] || []; * @param {Function} callback - The callback function */ -callbacks.add = function(hook, callback, priority, id = Random.id()) { - if (!_.isNumber(priority)) { - priority = callbacks.priority.MEDIUM; +callbacks.add = function( + hook, + callback, + priority = callbacks.priority.MEDIUM, + id = Random.id() +) { + callbacks[hook] = getHooks(hook); + if (callbacks[hook].find((cb) => cb.id === id)) { + return; } + callback.hook = hook; callback.priority = priority; callback.id = id; - callbacks[hook] = getHooks(hook); - - if (callbacks.showTime === true) { - const err = new Error(); - callback.stack = err.stack; - } + callback.stack = new Error().stack; - if (callbacks[hook].find((cb) => cb.id === callback.id)) { - return; - } callbacks[hook].push(callback); - callbacks[hook] = _.sortBy(callbacks[hook], function(callback) { - return callback.priority || callbacks.priority.MEDIUM; - }); + callbacks[hook] = _.sortBy(callbacks[hook], (callback) => callback.priority || callbacks.priority.MEDIUM); + combinedCallbacks.set(hook, create(hook, callbacks[hook])); }; @@ -67,11 +104,10 @@ callbacks.add = function(hook, callback, priority, id = Random.id()) { callbacks.remove = function(hook, id) { callbacks[hook] = getHooks(hook).filter((callback) => callback.id !== id); + combinedCallbacks.set(hook, create(hook, callbacks[hook])); }; -callbacks.runItem = function({ callback, result, constant /* , hook */ }) { - return callback(result, constant); -}; +callbacks.runItem = ({ callback, result, constant /* , hook */ }) => callback(result, constant); /* * Successively run all of a hook's callbacks on an item @@ -82,38 +118,18 @@ callbacks.runItem = function({ callback, result, constant /* , hook */ }) { */ callbacks.run = function(hook, item, constant) { - const callbackItems = callbacks[hook]; - if (!callbackItems || !callbackItems.length) { + const runner = combinedCallbacks.get(hook); + if (!runner) { return item; } - let totalTime = 0; - const result = callbackItems.reduce(function(result, callback) { - const time = callbacks.showTime === true || callbacks.showTotalTime === true ? Date.now() : 0; - - const callbackResult = callbacks.runItem({ hook, callback, result, constant, time }); - - if (callbacks.showTime === true || callbacks.showTotalTime === true) { - const currentTime = Date.now() - time; - totalTime += currentTime; - if (callbacks.showTime === true) { - if (!Meteor.isServer) { - let stack = callback.stack && typeof callback.stack.split === 'function' && callback.stack.split('\n'); - stack = stack && stack[2] && (stack[2].match(/\(.+\)/) || [])[0]; - console.log(String(currentTime), hook, callback.id, stack); - } - } - } - return typeof callbackResult === 'undefined' ? result : callbackResult; - }, item); - - if (callbacks.showTotalTime === true) { - if (!Meteor.isServer) { - console.log(`${ hook }:`, totalTime); - } - } + return runner(item, constant); - return result; + // return callbackItems.reduce(function(result, callback) { + // const callbackResult = callbacks.runItem({ hook, callback, result, constant }); + + // return typeof callbackResult === 'undefined' ? result : callbackResult; + // }, item); }; @@ -124,12 +140,10 @@ callbacks.run = function(hook, item, constant) { * @param {Object} [constant] - An optional constant that will be passed along to each callback */ -callbacks.runAsync = function(hook, item, constant) { +callbacks.runAsync = Meteor.isServer ? function(hook, item, constant) { const callbackItems = callbacks[hook]; - if (Meteor.isServer && callbackItems && callbackItems.length) { - Meteor.defer(function() { - callbackItems.forEach((callback) => callback(item, constant)); - }); + if (callbackItems && callbackItems.length) { + callbackItems.forEach((callback) => Meteor.defer(function() { callback(item, constant); })); } return item; -}; +} : () => { throw new Error('callbacks.runAsync on client server not allowed'); }; diff --git a/app/channel-settings/client/views/channelSettings.html b/app/channel-settings/client/views/channelSettings.html index 7e736cbeee8c..32f93b538b39 100644 --- a/app/channel-settings/client/views/channelSettings.html +++ b/app/channel-settings/client/views/channelSettings.html @@ -122,9 +122,9 @@ - {{_ "React_when_read_only"}} + {{_ "Disallow_reacting"}} - {{_ "React_when_read_only"}} + {{_ "Disallow_reacting_Description"}} @@ -136,9 +136,9 @@ - {{_ "Disallow_reacting"}} + {{_ "React_when_read_only"}} - {{_ "Disallow_reacting_Description"}} + {{_ "React_when_read_only"}} diff --git a/app/cloud/server/functions/connectWorkspace.js b/app/cloud/server/functions/connectWorkspace.js index 5ec79dbdb7e5..7f62a8b8b9c2 100644 --- a/app/cloud/server/functions/connectWorkspace.js +++ b/app/cloud/server/functions/connectWorkspace.js @@ -51,6 +51,7 @@ export function connectWorkspace(token) { Settings.updateValueById('Cloud_Workspace_Client_Id', data.client_id); Settings.updateValueById('Cloud_Workspace_Client_Secret', data.client_secret); Settings.updateValueById('Cloud_Workspace_Client_Secret_Expires_At', data.client_secret_expires_at); + Settings.updateValueById('Cloud_Workspace_PublicKey', data.publicKey); Settings.updateValueById('Cloud_Workspace_Registration_Client_Uri', data.registration_client_uri); // Now that we have the client id and secret, let's get the access token diff --git a/app/cloud/server/functions/getWorkspaceKey.js b/app/cloud/server/functions/getWorkspaceKey.js new file mode 100644 index 000000000000..912163ee02e5 --- /dev/null +++ b/app/cloud/server/functions/getWorkspaceKey.js @@ -0,0 +1,18 @@ +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { settings } from '../../../settings'; + +export function getWorkspaceKey() { + const { connectToCloud, workspaceRegistered } = retrieveRegistrationStatus(); + + if (!connectToCloud || !workspaceRegistered) { + return false; + } + + const publicKey = settings.get('Cloud_Workspace_PublicKey'); + + if (!publicKey) { + return false; + } + + return publicKey; +} diff --git a/app/cloud/server/functions/getWorkspaceLicense.js b/app/cloud/server/functions/getWorkspaceLicense.js index d769895ecfa3..5fbd1ff6fdf5 100644 --- a/app/cloud/server/functions/getWorkspaceLicense.js +++ b/app/cloud/server/functions/getWorkspaceLicense.js @@ -3,6 +3,7 @@ import { HTTP } from 'meteor/http'; import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; import { settings } from '../../../settings'; import { Settings } from '../../../models'; +import { callbacks } from '../../../callbacks'; export function getWorkspaceLicense() { @@ -39,5 +40,7 @@ export function getWorkspaceLicense() { Settings.updateValueById('Cloud_Workspace_License', remoteLicense.license); + callbacks.run('workspaceLicenseChanged', remoteLicense.license); + return { updated: true, license: remoteLicense.license }; } diff --git a/app/cloud/server/functions/startRegisterWorkspace.js b/app/cloud/server/functions/startRegisterWorkspace.js index e6c98490bde3..50a085745177 100644 --- a/app/cloud/server/functions/startRegisterWorkspace.js +++ b/app/cloud/server/functions/startRegisterWorkspace.js @@ -3,7 +3,7 @@ import { HTTP } from 'meteor/http'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { syncWorkspace } from './syncWorkspace'; import { settings } from '../../../settings'; -import { Settings } from '../../../models'; +import { Settings, Users } from '../../../models'; import { statistics } from '../../../statistics'; @@ -25,18 +25,34 @@ export function startRegisterWorkspace(resend = false) { const stats = statistics.get(); const address = settings.get('Site_Url'); + const siteName = settings.get('Site_Name'); // If we have it lets send it because likely an update const workspaceId = settings.get('Cloud_Workspace_Id'); + const firstUser = Users.getOldest({ name: 1, emails: 1 }); + const contactName = firstUser && firstUser.name; + let contactEmail = firstUser && firstUser.emails && firstUser.emails[0].address; + + if (settings.get('Organization_Email')) { + contactEmail = settings.get('Organization_Email'); + } + + const allowMarketing = settings.get('Allow_Marketing_Emails'); + + const accountName = settings.get('Organization_Name'); + const website = settings.get('Website'); + const regInfo = { uniqueId: stats.uniqueId, workspaceId, address, - contactName: stats.wizard.contactName, - contactEmail: stats.wizard.contactEmail, - accountName: stats.wizard.organizationName, - siteName: stats.wizard.siteName, + contactName, + contactEmail, + allowMarketing, + accountName, + website, + siteName, deploymentMethod: stats.deploy.method, deploymentPlatform: stats.deploy.platform, version: stats.version, diff --git a/app/cloud/server/functions/syncWorkspace.js b/app/cloud/server/functions/syncWorkspace.js index 93f1cddbc134..404806bdcc5f 100644 --- a/app/cloud/server/functions/syncWorkspace.js +++ b/app/cloud/server/functions/syncWorkspace.js @@ -5,6 +5,7 @@ import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; import { getWorkspaceLicense } from './getWorkspaceLicense'; import { statistics } from '../../../statistics'; +import { Settings } from '../../../models'; import { settings } from '../../../settings'; export function syncWorkspace(reconnectCheck = false) { @@ -16,14 +17,12 @@ export function syncWorkspace(reconnectCheck = false) { const stats = statistics.get(); const address = settings.get('Site_Url'); + const siteName = settings.get('Site_Name'); const info = { uniqueId: stats.uniqueId, address, - contactName: stats.wizard.contactName, - contactEmail: stats.wizard.contactEmail, - accountName: stats.wizard.organizationName, - siteName: stats.wizard.siteName, + siteName, deploymentMethod: stats.deploy.method, deploymentPlatform: stats.deploy.platform, version: stats.version, @@ -31,6 +30,7 @@ export function syncWorkspace(reconnectCheck = false) { const workspaceUrl = settings.get('Cloud_Workspace_Registration_Client_Uri'); + let result; try { const headers = {}; const token = getWorkspaceAccessToken(true); @@ -41,7 +41,7 @@ export function syncWorkspace(reconnectCheck = false) { return false; } - HTTP.post(`${ workspaceUrl }/client`, { + result = HTTP.post(`${ workspaceUrl }/client`, { data: info, headers, }); @@ -57,5 +57,11 @@ export function syncWorkspace(reconnectCheck = false) { return false; } + const { data } = result; + + if (data.publicKey) { + Settings.updateValueById('Cloud_Workspace_PublicKey', data.publicKey); + } + return true; } diff --git a/app/cloud/server/functions/unregisterWorkspace.js b/app/cloud/server/functions/unregisterWorkspace.js index a1102b1505d0..761932b0a34f 100644 --- a/app/cloud/server/functions/unregisterWorkspace.js +++ b/app/cloud/server/functions/unregisterWorkspace.js @@ -13,6 +13,7 @@ export function unregisterWorkspace() { Settings.updateValueById('Cloud_Workspace_Client_Id', null); Settings.updateValueById('Cloud_Workspace_Client_Secret', null); Settings.updateValueById('Cloud_Workspace_Client_Secret_Expires_At', null); + Settings.updateValueById('Cloud_Workspace_PublicKey', null); Settings.updateValueById('Cloud_Workspace_Registration_Client_Uri', null); return true; diff --git a/app/cloud/server/index.js b/app/cloud/server/index.js index 4a2e16941c00..81423b62bbdb 100644 --- a/app/cloud/server/index.js +++ b/app/cloud/server/index.js @@ -1,14 +1,33 @@ +import { Meteor } from 'meteor/meteor'; +import { SyncedCron } from 'meteor/littledata:synced-cron'; + import './methods'; import { getWorkspaceAccessToken } from './functions/getWorkspaceAccessToken'; import { getWorkspaceLicense } from './functions/getWorkspaceLicense'; import { getUserCloudAccessToken } from './functions/getUserCloudAccessToken'; +import { getWorkspaceKey } from './functions/getWorkspaceKey'; +import { syncWorkspace } from './functions/syncWorkspace'; import { Permissions } from '../../models'; if (Permissions) { Permissions.createOrUpdate('manage-cloud', ['admin']); } -// Ensure the client/workspace access token is valid -getWorkspaceAccessToken(); +const licenseCronName = 'Cloud Workspace Sync'; + +Meteor.startup(function() { + // run token/license sync if registered + syncWorkspace(); + + SyncedCron.remove(licenseCronName); + SyncedCron.add({ + name: licenseCronName, + schedule(parser) { + // Every 12 hours + return parser.cron('0 */12 * * *'); + }, + job: syncWorkspace, + }); +}); -export { getWorkspaceAccessToken, getWorkspaceLicense, getUserCloudAccessToken }; +export { getWorkspaceAccessToken, getWorkspaceLicense, getWorkspaceKey, getUserCloudAccessToken }; diff --git a/app/custom-oauth/server/custom_oauth_server.js b/app/custom-oauth/server/custom_oauth_server.js index 658262575003..5becea434c06 100644 --- a/app/custom-oauth/server/custom_oauth_server.js +++ b/app/custom-oauth/server/custom_oauth_server.js @@ -151,6 +151,7 @@ export class CustomOAuth { const params = {}; const headers = { 'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user + Accept: 'application/json', }; if (this.identityTokenSentVia === 'header') { diff --git a/app/discussion/server/hooks/joinDiscussionOnMessage.js b/app/discussion/server/hooks/joinDiscussionOnMessage.js index 08eb79071dc4..4884d47e1346 100644 --- a/app/discussion/server/hooks/joinDiscussionOnMessage.js +++ b/app/discussion/server/hooks/joinDiscussionOnMessage.js @@ -19,4 +19,4 @@ callbacks.add('beforeSaveMessage', (message, room) => { Meteor.runAsUser(message.u._id, () => Meteor.call('joinRoom', room._id)); return message; -}); +}, callbacks.priority.MEDIUM, 'joinDiscussionOnMessage'); diff --git a/app/dolphin/lib/common.js b/app/dolphin/lib/common.js index 36c95452a975..6f88e4c8b149 100644 --- a/app/dolphin/lib/common.js +++ b/app/dolphin/lib/common.js @@ -57,7 +57,7 @@ if (Meteor.isServer) { ServiceConfiguration.configurations.upsert({ service: 'dolphin' }, { $set: data }); } - callbacks.add('beforeCreateUser', DolphinOnCreateUser, callbacks.priority.HIGH); + callbacks.add('beforeCreateUser', DolphinOnCreateUser, callbacks.priority.HIGH, 'dolphin'); } else { Meteor.startup(() => Tracker.autorun(function() { diff --git a/app/e2e/server/index.js b/app/e2e/server/index.js index fdf3db07c92d..c9cfc3faca30 100644 --- a/app/e2e/server/index.js +++ b/app/e2e/server/index.js @@ -12,4 +12,4 @@ import './methods/requestSubscriptionKeys'; callbacks.add('afterJoinRoom', (user, room) => { Notifications.notifyRoom('e2e.keyRequest', room._id, room.e2eKeyId); -}); +}, callbacks.priority.MEDIUM, 'e2e'); diff --git a/app/emoji-custom/client/admin/adminEmoji.js b/app/emoji-custom/client/admin/adminEmoji.js index 3d64e3b189ce..7a46f4c9cb0c 100644 --- a/app/emoji-custom/client/admin/adminEmoji.js +++ b/app/emoji-custom/client/admin/adminEmoji.js @@ -42,6 +42,18 @@ Template.adminEmoji.helpers({ data: Template.instance().tabBarData.get(), }; }, + onTableScroll() { + const instance = Template.instance(); + return function(currentTarget) { + if ((currentTarget.offsetHeight + currentTarget.scrollTop) < (currentTarget.scrollHeight - 100)) { + return; + } + if (Template.instance().limit.get() > Template.instance().customemoji().length) { + return false; + } + instance.limit.set(instance.limit.get() + 50); + }; + }, onTableItemClick() { const instance = Template.instance(); return function({ _id }) { diff --git a/app/emoji-emojione/server/callbacks.js b/app/emoji-emojione/server/callbacks.js index fb88919f10b0..cd06854f3023 100644 --- a/app/emoji-emojione/server/callbacks.js +++ b/app/emoji-emojione/server/callbacks.js @@ -4,5 +4,5 @@ import emojione from 'emojione'; import { callbacks } from '../../callbacks'; Meteor.startup(function() { - callbacks.add('beforeSendMessageNotifications', (message) => emojione.shortnameToUnicode(message)); + callbacks.add('beforeSendMessageNotifications', (message) => emojione.shortnameToUnicode(message), callbacks.priority.MEDIUM, 'emojione-shortnameToUnicode'); }); diff --git a/app/emoji/client/emojiParser.js b/app/emoji/client/emojiParser.js index 1d7ad1f05c99..00ef84402bee 100644 --- a/app/emoji/client/emojiParser.js +++ b/app/emoji/client/emojiParser.js @@ -32,7 +32,32 @@ Tracker.autorun(() => { const emojis = Array.from(checkEmojiOnly.querySelectorAll('.emoji:not(:empty), .emojione:not(:empty)')); - const emojiOnly = emojis.length && !Array.from(checkEmojiOnly.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE).map((el) => el.nodeValue).join('').trim(); + const walker = document.createTreeWalker( + checkEmojiOnly, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + if (node.nodeType === Node.ELEMENT_NODE && ( + node.classList.contains('emojione') + || node.classList.contains('emoji') + )) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }, + ); + + let hasText = false; + + while (walker.nextNode()) { + if (walker.currentNode.nodeType === Node.TEXT_NODE && walker.currentNode.nodeValue.trim() !== '') { + hasText = true; + break; + } + } + + const emojiOnly = emojis.length && !hasText; if (emojiOnly) { for (let i = 0, len = emojis.length; i < len; i++) { diff --git a/app/federation/server/PeerClient.js b/app/federation/server/PeerClient.js index 8b6a0b144b3a..38ceabf807a8 100644 --- a/app/federation/server/PeerClient.js +++ b/app/federation/server/PeerClient.js @@ -5,9 +5,9 @@ import { Meteor } from 'meteor/meteor'; import { updateStatus } from './settingsUpdater'; import { logger } from './logger'; import { FederatedMessage, FederatedRoom, FederatedUser } from './federatedResources'; -import { callbacks } from '../../callbacks'; -import { settings } from '../../settings'; -import { FederationEvents, FederationKeys, Messages, Rooms, Subscriptions, Users } from '../../models'; +import { callbacks } from '../../callbacks/server'; +import { settings } from '../../settings/server'; +import { FederationEvents, FederationKeys, Messages, Rooms, Subscriptions, Users } from '../../models/server'; import { Federation } from '.'; @@ -232,9 +232,7 @@ export class PeerClient { // Should we use queues in here? const events = FederationEvents.getUnfulfilled(); - for (const e of events) { - this.propagateEvent(e); - } + events.forEach((e) => this.propagateEvent(e)); } // ##### diff --git a/app/federation/server/PeerServer/PeerServer.js b/app/federation/server/PeerServer/PeerServer.js index 73c51ba053e5..f6b2ecd5b794 100644 --- a/app/federation/server/PeerServer/PeerServer.js +++ b/app/federation/server/PeerServer/PeerServer.js @@ -58,7 +58,7 @@ export class PeerServer { federatedRoom.create(); // Refresh federation peers - FederationPeers.refreshPeers(); + FederationPeers.refreshPeers(localPeerDomain); } handleRoomCreatedEvent(e) { @@ -81,7 +81,7 @@ export class PeerServer { federatedRoom.create(true); // Refresh federation peers - FederationPeers.refreshPeers(); + FederationPeers.refreshPeers(localPeerDomain); } handleUserJoinedEvent(e) { @@ -111,7 +111,7 @@ export class PeerServer { federatedRoom.refreshFederation(); // Refresh federation peers - FederationPeers.refreshPeers(); + FederationPeers.refreshPeers(localPeerDomain); } handleUserAddedEvent(e) { @@ -150,7 +150,7 @@ export class PeerServer { federatedRoom.refreshFederation(); // Refresh federation peers - FederationPeers.refreshPeers(); + FederationPeers.refreshPeers(localPeerDomain); } handleUserLeftEvent(e) { @@ -180,7 +180,7 @@ export class PeerServer { federatedRoom.refreshFederation(); // Refresh federation peers - FederationPeers.refreshPeers(); + FederationPeers.refreshPeers(localPeerDomain); } handleUserRemovedEvent(e) { @@ -214,7 +214,7 @@ export class PeerServer { federatedRoom.refreshFederation(); // Refresh federation peers - FederationPeers.refreshPeers(); + FederationPeers.refreshPeers(localPeerDomain); } handleUserMutedEvent(e) { diff --git a/app/federation/server/config.js b/app/federation/server/config.js new file mode 100644 index 000000000000..b8f1f2b12287 --- /dev/null +++ b/app/federation/server/config.js @@ -0,0 +1,74 @@ +import mem from 'mem'; + +import { getWorkspaceAccessToken } from '../../cloud/server'; +import { FederationKeys } from '../../models/server'; +import { settings } from '../../settings/server'; +import * as SettingsUpdater from './settingsUpdater'; +import { logger } from './logger'; + +const defaultConfig = { + hub: { + active: null, + url: null, + }, + peer: { + uniqueId: null, + domain: null, + url: null, + public_key: null, + }, + cloud: { + token: null, + }, +}; + +const getConfigLocal = () => { + const _enabled = settings.get('FEDERATION_Enabled'); + + if (!_enabled) { return defaultConfig; } + + // If it is enabled, check if the settings are there + const _uniqueId = settings.get('FEDERATION_Unique_Id'); + const _domain = settings.get('FEDERATION_Domain'); + const _discoveryMethod = settings.get('FEDERATION_Discovery_Method'); + const _hubUrl = settings.get('FEDERATION_Hub_URL'); + const _peerUrl = settings.get('Site_Url'); + + if (!_domain || !_discoveryMethod || !_hubUrl || !_peerUrl) { + SettingsUpdater.updateStatus('Could not enable, settings are not fully set'); + + logger.setup.error('Could not enable Federation, settings are not fully set'); + + return defaultConfig; + } + + logger.setup.info('Updating settings...'); + + // Normalize the config values + return { + hub: { + active: _discoveryMethod === 'hub', + url: _hubUrl.replace(/\/+$/, ''), + }, + peer: { + uniqueId: _uniqueId, + domain: _domain.replace('@', '').trim(), + url: _peerUrl.replace(/\/+$/, ''), + public_key: FederationKeys.getPublicKeyString(), + }, + cloud: { + token: getWorkspaceAccessToken(), + }, + }; +}; + +export const getConfig = mem(getConfigLocal); + +const updateValue = () => mem.clear(getConfig); + +settings.get('FEDERATION_Enabled', updateValue); +settings.get('FEDERATION_Unique_Id', updateValue); +settings.get('FEDERATION_Domain', updateValue); +settings.get('FEDERATION_Status', updateValue); +settings.get('FEDERATION_Discovery_Method', updateValue); +settings.get('FEDERATION_Hub_URL', updateValue); diff --git a/app/federation/server/federatedResources/FederatedUser.js b/app/federation/server/federatedResources/FederatedUser.js index de6a7fee0652..5b21c5fd07f3 100644 --- a/app/federation/server/federatedResources/FederatedUser.js +++ b/app/federation/server/federatedResources/FederatedUser.js @@ -67,6 +67,9 @@ export class FederatedUser extends FederatedResource { localUser.username = user.username.split('@')[0]; localUser.name = user.name.split('@')[0]; } + if (federation.peer !== localPeerIdentifier) { + localUser.isRemote = true; + } return localUser; } diff --git a/app/federation/server/federation-settings.js b/app/federation/server/federation-settings.js index 333d45938f55..385d077af5f0 100644 --- a/app/federation/server/federation-settings.js +++ b/app/federation/server/federation-settings.js @@ -7,6 +7,8 @@ Meteor.startup(function() { // const federationUniqueId = FederationKeys.getUniqueId(); const federationPublicKey = FederationKeys.getPublicKeyString(); + const defaultHubURL = process.env.NODE_ENV === 'development' ? 'http://localhost:8080' : 'https://hub.rocket.chat'; + settings.addGroup('Federation', function() { this.add('FEDERATION_Enabled', false, { type: 'boolean', @@ -45,7 +47,7 @@ Meteor.startup(function() { i18nDescription: 'FEDERATION_Public_Key_Description', }); - this.add('FEDERATION_Hub_URL', 'https://hub.rocket.chat', { + this.add('FEDERATION_Hub_URL', defaultHubURL, { group: 'Federation Hub', type: 'string', i18nLabel: 'FEDERATION_Hub_URL', diff --git a/app/federation/server/index.js b/app/federation/server/index.js index 834a67c10f6a..f0c6844e86a1 100644 --- a/app/federation/server/index.js +++ b/app/federation/server/index.js @@ -13,9 +13,9 @@ import './methods/dashboard'; import { addUser } from './methods/addUser'; import { searchUsers } from './methods/searchUsers'; import { ping } from './methods/ping'; -import { getWorkspaceAccessToken } from '../../cloud/server'; import { FederationKeys } from '../../models'; import { settings } from '../../settings'; +import { getConfig } from './config'; const peerClient = new PeerClient(); const peerDNS = new PeerDNS(); @@ -73,39 +73,7 @@ const updateSettings = _.debounce(Meteor.bindEnvironment(function() { if (!_enabled) { return; } - // If it is enabled, check if the settings are there - const _uniqueId = settings.get('FEDERATION_Unique_Id'); - const _domain = settings.get('FEDERATION_Domain'); - const _discoveryMethod = settings.get('FEDERATION_Discovery_Method'); - const _hubUrl = settings.get('FEDERATION_Hub_URL'); - const _peerUrl = settings.get('Site_Url'); - - if (!_domain || !_discoveryMethod || !_hubUrl || !_peerUrl) { - SettingsUpdater.updateStatus('Could not enable, settings are not fully set'); - - logger.setup.error('Could not enable Federation, settings are not fully set'); - - return; - } - - logger.setup.info('Updating settings...'); - - // Normalize the config values - const config = { - hub: { - active: _discoveryMethod === 'hub', - url: _hubUrl.replace(/\/+$/, ''), - }, - peer: { - uniqueId: _uniqueId, - domain: _domain.replace('@', '').trim(), - url: _peerUrl.replace(/\/+$/, ''), - public_key: FederationKeys.getPublicKeyString(), - }, - cloud: { - token: getWorkspaceAccessToken(), - }, - }; + const config = getConfig(); // If the settings are correctly set, let's update the configuration diff --git a/app/federation/server/methods/dashboard.js b/app/federation/server/methods/dashboard.js index b05dd122a428..d272991cd641 100644 --- a/app/federation/server/methods/dashboard.js +++ b/app/federation/server/methods/dashboard.js @@ -1,19 +1,25 @@ import { Meteor } from 'meteor/meteor'; import moment from 'moment'; -import { FederationEvents, FederationPeers, Users } from '../../../models'; +// We do not import the whole Federation object here because statistics cron +// job use this file, and some of the features are not available on the cron +import { FederationEvents, FederationPeers, Users } from '../../../models/server'; -import { Federation } from '..'; +export function getStatistics() { + const numberOfEvents = FederationEvents.findByType('png').count(); + const numberOfFederatedUsers = Users.findRemote().count(); + const numberOfActivePeers = FederationPeers.findActiveRemote().count(); + const numberOfInactivePeers = FederationPeers.findNotActiveRemote().count(); + + return { numberOfEvents, numberOfFederatedUsers, numberOfActivePeers, numberOfInactivePeers }; +} export function federationGetOverviewData() { if (!Meteor.userId()) { throw new Meteor.Error('not-authorized'); } - const numberOfEvents = FederationEvents.find({ t: { $ne: 'png' } }).count(); - const numberOfFederatedUsers = Users.find({ federation: { $exists: true }, 'federation.peer': { $ne: Federation.localIdentifier } }).count(); - const numberOfActivePeers = FederationPeers.find({ active: true, peer: { $ne: Federation.localIdentifier } }).count(); - const numberOfInactivePeers = FederationPeers.find({ active: false, peer: { $ne: Federation.localIdentifier } }).count(); + const { numberOfEvents, numberOfFederatedUsers, numberOfActivePeers, numberOfInactivePeers } = getStatistics(); return { data: [{ @@ -37,7 +43,7 @@ export function federationGetPeerStatuses() { throw new Meteor.Error('not-authorized'); } - const peers = FederationPeers.find({ peer: { $ne: Federation.localIdentifier } }).fetch(); + const peers = FederationPeers.findRemote().fetch(); const peerStatuses = []; diff --git a/app/gitlab/lib/common.js b/app/gitlab/lib/common.js index 14410b4723f6..0160d04a2aea 100644 --- a/app/gitlab/lib/common.js +++ b/app/gitlab/lib/common.js @@ -1,13 +1,15 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; +import _ from 'underscore'; import { settings } from '../../settings'; import { CustomOAuth } from '../../custom-oauth'; const config = { serverURL: 'https://gitlab.com', - identityPath: '/api/v3/user', + identityPath: '/api/v4/user', scope: 'read_user', + mergeUsers: false, addAutopublishFields: { forLoggedInUser: ['services.gitlab'], forOtherUsers: ['services.gitlab.username'], @@ -19,16 +21,37 @@ const Gitlab = new CustomOAuth('gitlab', config); if (Meteor.isServer) { Meteor.startup(function() { - settings.get('API_Gitlab_URL', function(key, value) { - config.serverURL = value.trim().replace(/\/*$/, ''); + const updateConfig = _.debounce(() => { + config.serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL; + config.identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path') || config.identityPath; + config.mergeUsers = Boolean(settings.get('Accounts_OAuth_Gitlab_merge_users')); Gitlab.configure(config); - }); + }, 300); + + settings.get('API_Gitlab_URL', updateConfig); + settings.get('Accounts_OAuth_Gitlab_identity_path', updateConfig); + settings.get('Accounts_OAuth_Gitlab_merge_users', updateConfig); }); } else { Meteor.startup(function() { Tracker.autorun(function() { + let anyChange = false; if (settings.get('API_Gitlab_URL')) { config.serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, ''); + anyChange = true; + } + + if (settings.get('Accounts_OAuth_Gitlab_identity_path')) { + config.identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path').trim() || config.identityPath; + anyChange = true; + } + + if (settings.get('Accounts_OAuth_Gitlab_merge_users')) { + config.mergeUsers = true; + anyChange = true; + } + + if (anyChange) { Gitlab.configure(config); } }); diff --git a/app/gitlab/server/startup.js b/app/gitlab/server/startup.js index 70fc5d615618..2c98e63db7cb 100644 --- a/app/gitlab/server/startup.js +++ b/app/gitlab/server/startup.js @@ -11,6 +11,8 @@ settings.addGroup('OAuth', function() { this.add('API_Gitlab_URL', '', { type: 'string', enableQuery, public: true, secret: true }); this.add('Accounts_OAuth_Gitlab_id', '', { type: 'string', enableQuery }); this.add('Accounts_OAuth_Gitlab_secret', '', { type: 'string', enableQuery, secret: true }); + this.add('Accounts_OAuth_Gitlab_identity_path', '/api/v4/user', { type: 'string', public: true, enableQuery }); + this.add('Accounts_OAuth_Gitlab_merge_users', false, { type: 'boolean', public: true, enableQuery }); this.add('Accounts_OAuth_Gitlab_callback_url', '_oauth/gitlab', { type: 'relativeUrl', readonly: true, force: true, enableQuery }); }); }); diff --git a/app/google-vision/server/googlevision.js b/app/google-vision/server/googlevision.js index 658ef00f2631..35717a623243 100644 --- a/app/google-vision/server/googlevision.js +++ b/app/google-vision/server/googlevision.js @@ -35,7 +35,7 @@ class GoogleVision { callbacks.remove('beforeSaveMessage', 'googlevision-blockunsafe'); } }); - callbacks.add('afterFileUpload', this.annotate.bind(this)); + callbacks.add('afterFileUpload', this.annotate.bind(this), callbacks.priority.MEDIUM, 'GoogleVision'); } incCallCount(count) { diff --git a/app/graphql/server/resolvers/messages/chatMessageAdded.js b/app/graphql/server/resolvers/messages/chatMessageAdded.js index f8e3e54c455a..dc8ee0ddba7c 100644 --- a/app/graphql/server/resolvers/messages/chatMessageAdded.js +++ b/app/graphql/server/resolvers/messages/chatMessageAdded.js @@ -44,7 +44,7 @@ const resolver = { callbacks.add('afterSaveMessage', (message) => { publishMessage(message); -}, null, 'chatMessageAddedSubscription'); +}, callbacks.priority.MEDIUM, 'chatMessageAddedSubscription'); export { schema, diff --git a/app/integrations/server/triggers.js b/app/integrations/server/triggers.js index 576e7f1801af..c7c39c50cd5c 100644 --- a/app/integrations/server/triggers.js +++ b/app/integrations/server/triggers.js @@ -7,11 +7,11 @@ const callbackHandler = function _callbackHandler(eventType) { }; }; -callbacks.add('afterSaveMessage', callbackHandler('sendMessage'), callbacks.priority.LOW); -callbacks.add('afterCreateChannel', callbackHandler('roomCreated'), callbacks.priority.LOW); -callbacks.add('afterCreatePrivateGroup', callbackHandler('roomCreated'), callbacks.priority.LOW); -callbacks.add('afterCreateUser', callbackHandler('userCreated'), callbacks.priority.LOW); -callbacks.add('afterJoinRoom', callbackHandler('roomJoined'), callbacks.priority.LOW); -callbacks.add('afterLeaveRoom', callbackHandler('roomLeft'), callbacks.priority.LOW); -callbacks.add('afterRoomArchived', callbackHandler('roomArchived'), callbacks.priority.LOW); -callbacks.add('afterFileUpload', callbackHandler('fileUploaded'), callbacks.priority.LOW); +callbacks.add('afterSaveMessage', callbackHandler('sendMessage'), callbacks.priority.LOW, 'integrations-sendMessage'); +callbacks.add('afterCreateChannel', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated'); +callbacks.add('afterCreatePrivateGroup', callbackHandler('roomCreated'), callbacks.priority.LOW, 'integrations-roomCreated'); +callbacks.add('afterCreateUser', callbackHandler('userCreated'), callbacks.priority.LOW, 'integrations-userCreated'); +callbacks.add('afterJoinRoom', callbackHandler('roomJoined'), callbacks.priority.LOW, 'integrations-roomJoined'); +callbacks.add('afterLeaveRoom', callbackHandler('roomLeft'), callbacks.priority.LOW, 'integrations-roomLeft'); +callbacks.add('afterRoomArchived', callbackHandler('roomArchived'), callbacks.priority.LOW, 'integrations-roomArchived'); +callbacks.add('afterFileUpload', callbackHandler('fileUploaded'), callbacks.priority.LOW, 'integrations-fileUploaded'); diff --git a/app/ldap/server/sync.js b/app/ldap/server/sync.js index 550c079a697f..ac9d6fe33f0b 100644 --- a/app/ldap/server/sync.js +++ b/app/ldap/server/sync.js @@ -144,7 +144,7 @@ export function getDataToSyncUserData(ldapUser, user) { if (currKey === lastKey) { obj[currKey] = tmpLdapField; } else { - obj[currKey] = obj[currKey]; + obj[currKey] = obj[currKey] || {}; } return obj[currKey]; }, userData); diff --git a/app/lib/client/methods/sendMessage.js b/app/lib/client/methods/sendMessage.js index 4ba64c0059a7..d765eefe27c7 100644 --- a/app/lib/client/methods/sendMessage.js +++ b/app/lib/client/methods/sendMessage.js @@ -1,17 +1,23 @@ import { Meteor } from 'meteor/meteor'; import { TimeSync } from 'meteor/mizzao:timesync'; import s from 'underscore.string'; +import toastr from 'toastr'; import { ChatMessage } from '../../../models'; import { settings } from '../../../settings'; import { callbacks } from '../../../callbacks'; import { promises } from '../../../promises/client'; +import { t } from '../../../utils/client'; Meteor.methods({ sendMessage(message) { if (!Meteor.userId() || s.trim(message.msg) === '') { return false; } + const messageAlreadyExists = message._id && ChatMessage.findOne({ _id: message._id }); + if (messageAlreadyExists) { + return toastr.error(t('Message_Already_Sent')); + } const user = Meteor.user(); message.ts = isNaN(TimeSync.serverOffset()) ? new Date() : new Date(Date.now() + TimeSync.serverOffset()); message.u = { diff --git a/app/lib/server/functions/checkUsernameAvailability.js b/app/lib/server/functions/checkUsernameAvailability.js index e881786805aa..064753575d59 100644 --- a/app/lib/server/functions/checkUsernameAvailability.js +++ b/app/lib/server/functions/checkUsernameAvailability.js @@ -8,7 +8,7 @@ let usernameBlackList = []; const toRegExp = (username) => new RegExp(`^${ s.escapeRegExp(username).trim() }$`, 'i'); settings.get('Accounts_BlockedUsernameList', (key, value) => { - usernameBlackList = value.split(',').map(toRegExp); + usernameBlackList = ['all', 'here'].concat(value.split(',')).map(toRegExp); }); const usernameIsBlocked = (username, usernameBlackList) => usernameBlackList.length diff --git a/app/lib/server/functions/deleteUser.js b/app/lib/server/functions/deleteUser.js index 537732d1617f..e96c9e36f6ac 100644 --- a/app/lib/server/functions/deleteUser.js +++ b/app/lib/server/functions/deleteUser.js @@ -6,6 +6,7 @@ import { Users, Subscriptions, Messages, Rooms, Integrations, FederationPeers } import { hasRole, getUsersInRole } from '../../../authorization'; import { settings } from '../../../settings'; import { Notifications } from '../../../notifications'; +import { getConfig } from '../../../federation/server/config'; export const deleteUser = function(userId) { const user = Users.findOneById(userId, { @@ -99,5 +100,6 @@ export const deleteUser = function(userId) { Users.removeById(userId); // Remove user from users database // Refresh the peers list - FederationPeers.refreshPeers(); + const { peer: { domain: localPeerDomain } } = getConfig(); + FederationPeers.refreshPeers(localPeerDomain); }; diff --git a/app/lib/server/functions/sendMessage.js b/app/lib/server/functions/sendMessage.js index 290218f14e3d..d12668baaacf 100644 --- a/app/lib/server/functions/sendMessage.js +++ b/app/lib/server/functions/sendMessage.js @@ -45,7 +45,7 @@ const validateAttachmentsFields = (attachmentField) => { check(attachmentField, objectMaybeIncluding({ short: Boolean, title: String, - value: Match.OneOf(String, Match.Integer, Boolean), + value: Match.OneOf(String, Number, Boolean), })); if (typeof attachmentField.value !== 'undefined') { @@ -198,6 +198,10 @@ export const sendMessage = function(user, message, room, upsert = false) { }, message); message._id = _id; } else { + const messageAlreadyExists = message._id && Messages.findOneById(message._id, { fields: { _id: 1 } }); + if (messageAlreadyExists) { + return; + } message._id = Messages.insert(message); } diff --git a/app/lib/server/functions/setStatusText.js b/app/lib/server/functions/setStatusText.js index c852f21a6ab0..3b06c36ab28d 100644 --- a/app/lib/server/functions/setStatusText.js +++ b/app/lib/server/functions/setStatusText.js @@ -45,7 +45,7 @@ export const _setStatusText = function(userId, statusText) { return true; }; -export const setStatusText = RateLimiter.limitFunction(_setStatusText, 1, 60000, { +export const setStatusText = RateLimiter.limitFunction(_setStatusText, 5, 60000, { 0() { // Administrators have permission to change others status, so don't limit those return !Meteor.userId() || !hasPermission(Meteor.userId(), 'edit-other-user-info'); diff --git a/app/lib/server/index.js b/app/lib/server/index.js index dfbf8f7b010e..aa0f7c468bfc 100644 --- a/app/lib/server/index.js +++ b/app/lib/server/index.js @@ -65,7 +65,6 @@ import './methods/setUsername'; import './methods/unarchiveRoom'; import './methods/unblockUser'; import './methods/updateMessage'; -import './publications/settings'; export * from './lib'; export * from './functions'; diff --git a/app/lib/server/methods/deleteUserOwnAccount.js b/app/lib/server/methods/deleteUserOwnAccount.js index 62c27450c3a8..0b5670abd25c 100644 --- a/app/lib/server/methods/deleteUserOwnAccount.js +++ b/app/lib/server/methods/deleteUserOwnAccount.js @@ -27,7 +27,10 @@ Meteor.methods({ } if (user.services && user.services.password && s.trim(user.services.password.bcrypt)) { - const result = Accounts._checkPassword(user, { digest: password, algorithm: 'sha-256' }); + const result = Accounts._checkPassword(user, { + digest: password.toLowerCase(), + algorithm: 'sha-256', + }); if (result.error) { throw new Meteor.Error('error-invalid-password', 'Invalid password', { method: 'deleteUserOwnAccount' }); } diff --git a/app/lib/server/publications/settings.js b/app/lib/server/publications/settings.js deleted file mode 100644 index dec4c0a6d4ae..000000000000 --- a/app/lib/server/publications/settings.js +++ /dev/null @@ -1,99 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Settings } from '../../../models'; -import { hasPermission } from '../../../authorization'; -import { Notifications } from '../../../notifications'; - -Meteor.methods({ - 'public-settings/get'(updatedAt) { - const records = Settings.findNotHiddenPublic().fetch(); - - if (updatedAt instanceof Date) { - return { - update: records.filter(function(record) { - return record._updatedAt > updatedAt; - }), - remove: Settings.trashFindDeletedAfter(updatedAt, { - hidden: { - $ne: true, - }, - public: true, - }, { - fields: { - _id: 1, - _deletedAt: 1, - }, - }).fetch(), - }; - } - return records; - }, - 'private-settings/get'(updatedAfter) { - if (!Meteor.userId()) { - return []; - } - if (!hasPermission(Meteor.userId(), 'view-privileged-setting')) { - return []; - } - - if (!(updatedAfter instanceof Date)) { - return Settings.findNotHidden().fetch(); - } - - const records = Settings.findNotHidden({ updatedAfter }).fetch(); - return { - update: records, - remove: Settings.trashFindDeletedAfter(updatedAfter, { - hidden: { - $ne: true, - }, - }, { - fields: { - _id: 1, - _deletedAt: 1, - }, - }).fetch(), - }; - }, -}); - -Settings.on('change', ({ clientAction, id, data, diff }) => { - if (diff && Object.keys(diff).length === 1 && diff._updatedAt) { // avoid useless changes - return; - } - switch (clientAction) { - case 'updated': - case 'inserted': { - const setting = data || Settings.findOneById(id); - const value = { - _id: setting._id, - value: setting.value, - editor: setting.editor, - properties: setting.properties, - }; - - if (setting.public === true) { - Notifications.notifyAllInThisInstance('public-settings-changed', clientAction, value); - } - Notifications.notifyLoggedInThisInstance('private-settings-changed', clientAction, setting); - break; - } - - case 'removed': { - const setting = data || Settings.findOneById(id, { fields: { public: 1 } }); - - if (setting && setting.public === true) { - Notifications.notifyAllInThisInstance('public-settings-changed', clientAction, { _id: id }); - } - Notifications.notifyLoggedInThisInstance('private-settings-changed', clientAction, { _id: id }); - break; - } - } -}); - -Notifications.streamAll.allowRead('private-settings-changed', function() { - if (this.userId == null) { - return false; - } - return hasPermission(this.userId, 'view-privileged-setting'); -}); diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index 9d83ead14338..adf0e1893a22 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -2664,6 +2664,17 @@ settings.addGroup('Setup_Wizard', function() { secret: true, }); + this.add('Cloud_Workspace_PublicKey', '', { + type: 'string', + hidden: true, + readonly: true, + enableQuery: { + _id: 'Register_Server', + value: true, + }, + secret: true, + }); + this.add('Cloud_Workspace_License', '', { type: 'string', hidden: true, diff --git a/app/livechat/client/index.js b/app/livechat/client/index.js index 5e48aa915937..6eff1d3748e5 100644 --- a/app/livechat/client/index.js +++ b/app/livechat/client/index.js @@ -3,7 +3,6 @@ import '../lib/LivechatExternalMessage'; import './roomType'; import './route'; import './ui'; -import './methods/changeLivechatStatus'; import './startup/notifyUnreadRooms'; import './views/app/analytics/livechatAnalytics'; import './views/app/analytics/livechatAnalyticsCustomDaterange'; diff --git a/app/livechat/client/lib/dataHandler.js b/app/livechat/client/lib/dataHandler.js index abba76521c57..d1b14b24272b 100644 --- a/app/livechat/client/lib/dataHandler.js +++ b/app/livechat/client/lib/dataHandler.js @@ -190,10 +190,10 @@ export const getTimingsOverviewData = (dbCursor) => { }); return [{ - title: 'Avg_response_time', + title: 'Avg_reaction_time', value: total ? secondsToHHMMSS((totalReactionTime / total).toFixed(2)) : '-', }, { - title: 'Avg_reaction_time', + title: 'Avg_response_time', value: total ? secondsToHHMMSS((totalResponseTime / total).toFixed(2)) : '-', }]; }; diff --git a/app/livechat/client/methods/changeLivechatStatus.js b/app/livechat/client/methods/changeLivechatStatus.js deleted file mode 100644 index d98d97ca9038..000000000000 --- a/app/livechat/client/methods/changeLivechatStatus.js +++ /dev/null @@ -1,15 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -Meteor.methods({ - 'livechat:changeLivechatStatus'() { - if (!Meteor.userId()) { - return false; - } - - const user = Meteor.user(); - - const newStatus = user.statusLivechat === 'available' ? 'not-available' : 'available'; - - Meteor.users.update(user._id, { $set: { statusLivechat: newStatus } }); - }, -}); diff --git a/app/livechat/client/views/app/livechatOfficeHours.html b/app/livechat/client/views/app/livechatOfficeHours.html index 4c949f4f42c9..5aab0f332687 100644 --- a/app/livechat/client/views/app/livechatOfficeHours.html +++ b/app/livechat/client/views/app/livechatOfficeHours.html @@ -11,6 +11,14 @@ {{_ "False"}} + + {{_ "Allow_Online_Agents_Outside_Office_Hours"}} + + {{_ "True"}} + + {{_ "False"}} + + {{_ "Open_days_of_the_week"}} diff --git a/app/livechat/client/views/app/livechatOfficeHours.js b/app/livechat/client/views/app/livechatOfficeHours.js index 34de2ee6d02a..870128a2824e 100644 --- a/app/livechat/client/views/app/livechatOfficeHours.js +++ b/app/livechat/client/views/app/livechatOfficeHours.js @@ -45,6 +45,16 @@ Template.livechatOfficeHours.helpers({ return 'checked'; } }, + allowAgentsOnlineOutOfficeHoursTrueChecked() { + if (Template.instance().allowAgentsOnlineOutOfficeHours.get()) { + return 'checked'; + } + }, + allowAgentsOnlineOutOfficeHoursFalseChecked() { + if (!Template.instance().allowAgentsOnlineOutOfficeHours.get()) { + return 'checked'; + } + }, }); Template.livechatOfficeHours.events({ @@ -97,6 +107,8 @@ Template.livechatOfficeHours.events({ } } + settings.set('Livechat_allow_online_agents_outside_office_hours', instance.allowAgentsOnlineOutOfficeHours.get()); + settings.set('Livechat_enable_office_hours', instance.enableOfficeHours.get(), (err/* , success*/) => { if (err) { return handleError(err); @@ -158,8 +170,10 @@ Template.livechatOfficeHours.onCreated(function() { }); this.enableOfficeHours = new ReactiveVar(null); + this.allowAgentsOnlineOutOfficeHours = new ReactiveVar(null); this.autorun(() => { this.enableOfficeHours.set(settings.get('Livechat_enable_office_hours')); + this.allowAgentsOnlineOutOfficeHours.set(settings.get('Livechat_allow_online_agents_outside_office_hours')); }); }); diff --git a/app/livechat/server/api/v1/pageVisited.js b/app/livechat/server/api/v1/pageVisited.js index 4b35cd3adb6e..e5ef7c42ba64 100644 --- a/app/livechat/server/api/v1/pageVisited.js +++ b/app/livechat/server/api/v1/pageVisited.js @@ -1,9 +1,7 @@ -import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import _ from 'underscore'; import { API } from '../../../../api'; -import { findGuest, findRoom } from '../lib/livechat'; import { Livechat } from '../../lib/Livechat'; API.v1.addRoute('livechat/page.visited', { @@ -11,7 +9,7 @@ API.v1.addRoute('livechat/page.visited', { try { check(this.bodyParams, { token: String, - rid: String, + rid: Match.Maybe(String), pageInfo: Match.ObjectIncluding({ change: String, title: String, @@ -22,17 +20,6 @@ API.v1.addRoute('livechat/page.visited', { }); const { token, rid, pageInfo } = this.bodyParams; - - const guest = findGuest(token); - if (!guest) { - throw new Meteor.Error('invalid-token'); - } - - const room = findRoom(token, rid); - if (!room) { - throw new Meteor.Error('invalid-room'); - } - const obj = Livechat.savePageHistory(token, rid, pageInfo); if (obj) { const page = _.pick(obj, 'msg', 'navigation'); diff --git a/app/livechat/server/config.js b/app/livechat/server/config.js index c3d85b5afed3..2f11b4b24606 100644 --- a/app/livechat/server/config.js +++ b/app/livechat/server/config.js @@ -272,6 +272,14 @@ Meteor.startup(function() { i18nLabel: 'Office_hours_enabled', }); + settings.add('Livechat_allow_online_agents_outside_office_hours', true, { + type: 'boolean', + group: 'Livechat', + public: true, + i18nLabel: 'Allow_Online_Agents_Outside_Office_Hours', + enableQuery: { _id: 'Livechat_enable_office_hours', value: true }, + }); + settings.add('Livechat_continuous_sound_notification_new_livechat_room', false, { type: 'boolean', group: 'Livechat', diff --git a/app/livechat/server/hooks/externalMessage.js b/app/livechat/server/hooks/externalMessage.js index 91b9448be491..2ef76fbb88f1 100644 --- a/app/livechat/server/hooks/externalMessage.js +++ b/app/livechat/server/hooks/externalMessage.js @@ -1,4 +1,3 @@ -import { Meteor } from 'meteor/meteor'; import { HTTP } from 'meteor/http'; import _ from 'underscore'; @@ -39,32 +38,30 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; } - Meteor.defer(() => { - try { - const response = HTTP.post('https://api.api.ai/api/query?v=20150910', { - data: { - query: message.msg, - lang: apiaiLanguage, - sessionId: room._id, - }, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - Authorization: `Bearer ${ apiaiKey }`, - }, - }); + try { + const response = HTTP.post('https://api.api.ai/api/query?v=20150910', { + data: { + query: message.msg, + lang: apiaiLanguage, + sessionId: room._id, + }, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Authorization: `Bearer ${ apiaiKey }`, + }, + }); - if (response.data && response.data.status.code === 200 && !_.isEmpty(response.data.result.fulfillment.speech)) { - LivechatExternalMessage.insert({ - rid: message.rid, - msg: response.data.result.fulfillment.speech, - orig: message._id, - ts: new Date(), - }); - } - } catch (e) { - SystemLogger.error('Error using Api.ai ->', e); + if (response.data && response.data.status.code === 200 && !_.isEmpty(response.data.result.fulfillment.speech)) { + LivechatExternalMessage.insert({ + rid: message.rid, + msg: response.data.result.fulfillment.speech, + orig: message._id, + ts: new Date(), + }); } - }); + } catch (e) { + SystemLogger.error('Error using Api.ai ->', e); + } return message; }, callbacks.priority.LOW, 'externalWebHook'); diff --git a/app/livechat/server/hooks/markRoomResponded.js b/app/livechat/server/hooks/markRoomResponded.js index 4f629f901aaf..ed1be72106e6 100644 --- a/app/livechat/server/hooks/markRoomResponded.js +++ b/app/livechat/server/hooks/markRoomResponded.js @@ -1,4 +1,3 @@ -import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../callbacks'; import { Rooms } from '../../../models'; @@ -19,13 +18,11 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; } - Meteor.defer(() => { - Rooms.setResponseByRoomId(room._id, { - user: { - _id: message.u._id, - username: message.u.username, - }, - }); + Rooms.setResponseByRoomId(room._id, { + user: { + _id: message.u._id, + username: message.u.username, + }, }); return message; diff --git a/app/livechat/server/hooks/saveAnalyticsData.js b/app/livechat/server/hooks/saveAnalyticsData.js index 582fa0d9cc37..58bed9f144d4 100644 --- a/app/livechat/server/hooks/saveAnalyticsData.js +++ b/app/livechat/server/hooks/saveAnalyticsData.js @@ -1,5 +1,3 @@ -import { Meteor } from 'meteor/meteor'; - import { callbacks } from '../../../callbacks'; import { Rooms } from '../../../models'; @@ -14,54 +12,54 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; } - Meteor.defer(() => { - const now = new Date(); - let analyticsData; - // if the message has a token, it was sent by the visitor - if (!message.token) { - const visitorLastQuery = room.metrics && room.metrics.v ? room.metrics.v.lq : room.ts; - const agentLastReply = room.metrics && room.metrics.servedBy ? room.metrics.servedBy.lr : room.ts; - const agentJoinTime = room.servedBy && room.servedBy.ts ? room.servedBy.ts : room.ts; + const now = new Date(); + let analyticsData; + + // if the message has a token, it was sent by the visitor + if (!message.token) { + const visitorLastQuery = room.metrics && room.metrics.v ? room.metrics.v.lq : room.ts; + const agentLastReply = room.metrics && room.metrics.servedBy ? room.metrics.servedBy.lr : room.ts; + const agentJoinTime = room.servedBy && room.servedBy.ts ? room.servedBy.ts : room.ts; - const isResponseTt = room.metrics && room.metrics.response && room.metrics.response.tt; - const isResponseTotal = room.metrics && room.metrics.response && room.metrics.response.total; + const isResponseTt = room.metrics && room.metrics.response && room.metrics.response.tt; + const isResponseTotal = room.metrics && room.metrics.response && room.metrics.response.total; - if (agentLastReply === room.ts) { // first response - const firstResponseDate = now; - const firstResponseTime = (now.getTime() - visitorLastQuery) / 1000; - const responseTime = (now.getTime() - visitorLastQuery) / 1000; - const avgResponseTime = ((isResponseTt ? room.metrics.response.tt : 0) + responseTime) / ((isResponseTotal ? room.metrics.response.total : 0) + 1); + if (agentLastReply === room.ts) { // first response + const firstResponseDate = now; + const firstResponseTime = (now.getTime() - visitorLastQuery) / 1000; + const responseTime = (now.getTime() - visitorLastQuery) / 1000; + const avgResponseTime = ((isResponseTt ? room.metrics.response.tt : 0) + responseTime) / ((isResponseTotal ? room.metrics.response.total : 0) + 1); - const firstReactionDate = now; - const firstReactionTime = (now.getTime() - agentJoinTime) / 1000; - const reactionTime = (now.getTime() - agentJoinTime) / 1000; + const firstReactionDate = now; + const firstReactionTime = (now.getTime() - agentJoinTime) / 1000; + const reactionTime = (now.getTime() - agentJoinTime) / 1000; - analyticsData = { - firstResponseDate, - firstResponseTime, - responseTime, - avgResponseTime, - firstReactionDate, - firstReactionTime, - reactionTime, - }; - } else if (visitorLastQuery > agentLastReply) { // response, not first - const responseTime = (now.getTime() - visitorLastQuery) / 1000; - const avgResponseTime = ((isResponseTt ? room.metrics.response.tt : 0) + responseTime) / ((isResponseTotal ? room.metrics.response.total : 0) + 1); + analyticsData = { + firstResponseDate, + firstResponseTime, + responseTime, + avgResponseTime, + firstReactionDate, + firstReactionTime, + reactionTime, + }; + } else if (visitorLastQuery > agentLastReply) { // response, not first + const responseTime = (now.getTime() - visitorLastQuery) / 1000; + const avgResponseTime = ((isResponseTt ? room.metrics.response.tt : 0) + responseTime) / ((isResponseTotal ? room.metrics.response.total : 0) + 1); - const reactionTime = (now.getTime() - visitorLastQuery) / 1000; + const reactionTime = (now.getTime() - visitorLastQuery) / 1000; + + analyticsData = { + responseTime, + avgResponseTime, + reactionTime, + }; + } // ignore, its continuing response + } - analyticsData = { - responseTime, - avgResponseTime, - reactionTime, - }; - } // ignore, its continuing response - } + Rooms.saveAnalyticsDataByRoomId(room, message, analyticsData); - Rooms.saveAnalyticsDataByRoomId(room, message, analyticsData); - }); return message; }, callbacks.priority.LOW, 'saveAnalyticsData'); diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index 2119a53f9084..6b487baeb71b 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -14,7 +14,18 @@ import { QueueMethods } from './QueueMethods'; import { Analytics } from './Analytics'; import { settings } from '../../../settings'; import { callbacks } from '../../../callbacks'; -import { Users, Rooms, Messages, Subscriptions, Settings, LivechatDepartmentAgents, LivechatDepartment, LivechatCustomField, LivechatVisitors } from '../../../models'; +import { + Users, + Rooms, + Messages, + Subscriptions, + Settings, + LivechatDepartmentAgents, + LivechatDepartment, + LivechatCustomField, + LivechatVisitors, + LivechatOfficeHour, +} from '../../../models'; import { Logger } from '../../../logger'; import { sendMessage, deleteMessage, updateMessage } from '../../../lib'; import { addUserRoles, removeUserFromRoles } from '../../../authorization'; @@ -928,6 +939,22 @@ export const Livechat = { }); }); }, + + allowAgentChangeServiceStatus(statusLivechat) { + if (!settings.get('Livechat_enable_office_hours')) { + return true; + } + + if (settings.get('Livechat_allow_online_agents_outside_office_hours')) { + return true; + } + + if (statusLivechat !== 'available') { + return true; + } + + return LivechatOfficeHour.isNowWithinHours(); + }, }; Livechat.stream = new Meteor.Streamer('livechat-room'); diff --git a/app/livechat/server/methods/changeLivechatStatus.js b/app/livechat/server/methods/changeLivechatStatus.js index e61250830360..70fd73e2ff4a 100644 --- a/app/livechat/server/methods/changeLivechatStatus.js +++ b/app/livechat/server/methods/changeLivechatStatus.js @@ -1,6 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Users } from '../../../models'; +import { Livechat } from '../lib/Livechat'; Meteor.methods({ 'livechat:changeLivechatStatus'() { @@ -11,6 +12,9 @@ Meteor.methods({ const user = Meteor.user(); const newStatus = user.statusLivechat === 'available' ? 'not-available' : 'available'; + if (!Livechat.allowAgentChangeServiceStatus(newStatus)) { + throw new Meteor.Error('error-office-hours-are-closed', 'Not allowed', { method: 'livechat:changeLivechatStatus' }); + } return Users.setLivechatStatus(user._id, newStatus); }, diff --git a/app/logger/server/server.js b/app/logger/server/server.js index 8e9bc460dc35..6d15ed22883a 100644 --- a/app/logger/server/server.js +++ b/app/logger/server/server.js @@ -87,6 +87,11 @@ const defaultTypes = { color: 'red', level: 0, }, + deprecation: { + name: 'warn', + color: 'magenta', + level: 0, + }, }; class _Logger { diff --git a/app/message-pin/client/actionButton.js b/app/message-pin/client/actionButton.js index 1fcf1f19cc21..7ea7f5913f4e 100644 --- a/app/message-pin/client/actionButton.js +++ b/app/message-pin/client/actionButton.js @@ -64,7 +64,7 @@ Meteor.startup(function() { id: 'jump-to-pin-message', icon: 'jump', label: 'Jump_to_message', - context: ['pinned'], + context: ['pinned', 'message', 'message-mobile'], action() { const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { diff --git a/app/message-star/client/actionButton.js b/app/message-star/client/actionButton.js index fee7c64c2dba..c6898ef9d50f 100644 --- a/app/message-star/client/actionButton.js +++ b/app/message-star/client/actionButton.js @@ -63,7 +63,7 @@ Meteor.startup(function() { id: 'jump-to-star-message', icon: 'jump', label: 'Jump_to_message', - context: ['starred', 'threads'], + context: ['starred', 'threads', 'message', 'message-mobile'], action() { const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { diff --git a/app/meteor-accounts-saml/server/saml_rocketchat.js b/app/meteor-accounts-saml/server/saml_rocketchat.js index fdb9ad837f88..7234b5e29eb2 100644 --- a/app/meteor-accounts-saml/server/saml_rocketchat.js +++ b/app/meteor-accounts-saml/server/saml_rocketchat.js @@ -122,6 +122,12 @@ Meteor.methods({ section: name, i18nLabel: 'SAML_Custom_Logout_Behaviour', }); + settings.add(`SAML_Custom_${ name }_custom_authn_context`, 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', { + type: 'string', + group: 'SAML', + section: name, + i18nLabel: 'SAML_Custom_Authn_Context', + }); }, }); @@ -149,6 +155,7 @@ const getSamlConfigs = function(service) { mailOverwrite: settings.get(`${ service.key }_mail_overwrite`), issuer: settings.get(`${ service.key }_issuer`), logoutBehaviour: settings.get(`${ service.key }_logout_behaviour`), + customAuthnContext: settings.get(`${ service.key }_custom_authn_context`), secret: { privateKey: settings.get(`${ service.key }_private_key`), publicCert: settings.get(`${ service.key }_public_cert`), @@ -193,6 +200,7 @@ const configureSamlService = function(samlConfigs) { cert: samlConfigs.secret.cert, privateCert, privateKey, + customAuthnContext: samlConfigs.customAuthnContext, }; }; diff --git a/app/meteor-accounts-saml/server/saml_server.js b/app/meteor-accounts-saml/server/saml_server.js index eedb919bd69e..65386015cac6 100644 --- a/app/meteor-accounts-saml/server/saml_server.js +++ b/app/meteor-accounts-saml/server/saml_server.js @@ -116,7 +116,7 @@ Accounts.registerLoginHandler(function(loginRequest) { const emailRegex = new RegExp(emailList.map((email) => `^${ RegExp.escape(email) }$`).join('|'), 'i'); const eduPersonPrincipalName = loginResult.profile.eppn; - const fullName = loginResult.profile.cn || loginResult.profile.username || loginResult.profile.displayName; + const fullName = loginResult.profile.cn || loginResult.profile.displayName || loginResult.profile.username; let eppnMatch = false; let user = null; diff --git a/app/meteor-accounts-saml/server/saml_utils.js b/app/meteor-accounts-saml/server/saml_utils.js index b910ff6c15d5..37313596a222 100644 --- a/app/meteor-accounts-saml/server/saml_utils.js +++ b/app/meteor-accounts-saml/server/saml_utils.js @@ -96,9 +96,10 @@ SAML.prototype.generateAuthorizeRequest = function(req) { request += `\n`; } + const authnContext = this.options.customAuthnContext || 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'; request += '' - + 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport\n' + + `${ authnContext }\n` + ''; return request; diff --git a/app/metrics/server/callbacksMetrics.js b/app/metrics/server/callbacksMetrics.js index 8402f6fa9f8e..86221f5d55f2 100644 --- a/app/metrics/server/callbacksMetrics.js +++ b/app/metrics/server/callbacksMetrics.js @@ -18,12 +18,15 @@ callbacks.run = function(hook, item, constant) { return result; }; -callbacks.runItem = function({ callback, result, constant, hook, time }) { +callbacks.runItem = function({ callback, result, constant, hook, time = Date.now() }) { const rocketchatCallbacksEnd = metrics.rocketchatCallbacks.startTimer({ hook, callback: callback.id }); const newResult = originalRunItem({ callback, result, constant }); - StatsTracker.timing('callbacks.time', Date.now() - time, [`hook:${ hook }`, `callback:${ callback.id }`]); + StatsTracker.timing('callbacks.time', Date.now() - time, [ + `hook:${ hook }`, + `callback:${ callback.id }`, + ]); rocketchatCallbacksEnd(); diff --git a/app/models/server/models/ExportOperations.js b/app/models/server/models/ExportOperations.js index a72fdc486e20..72c4be71ca1b 100644 --- a/app/models/server/models/ExportOperations.js +++ b/app/models/server/models/ExportOperations.js @@ -46,6 +46,15 @@ export class ExportOperations extends Base { return this.find(query, options); } + findAllPendingBeforeMyRequest(requestDay, options) { + const query = { + status: { $nin: ['completed'] }, + createdAt: { $lt: requestDay }, + }; + + return this.find(query, options); + } + // UPDATE updateOperation(data) { const update = { diff --git a/app/models/server/models/FederationEvents.js b/app/models/server/models/FederationEvents.js index 82a09247c0e6..55dbcb366acd 100644 --- a/app/models/server/models/FederationEvents.js +++ b/app/models/server/models/FederationEvents.js @@ -20,6 +20,10 @@ const normalizePeers = (basePeers, options) => { class FederationEventsModel extends Base { constructor() { super('federation_events'); + + this.tryEnsureIndex({ t: 1 }); + this.tryEnsureIndex({ fulfilled: 1 }); + this.tryEnsureIndex({ ts: 1 }); } // Sometimes events errored but the error is final @@ -256,7 +260,11 @@ class FederationEventsModel extends Base { // Get all unfulfilled events getUnfulfilled() { - return this.find({ fulfilled: false }, { sort: { ts: 1 } }).fetch(); + return this.find({ fulfilled: false }, { sort: { ts: 1 } }); + } + + findByType(t) { + return this.find({ t }); } } diff --git a/app/models/server/models/FederationPeers.js b/app/models/server/models/FederationPeers.js index 75acdcd44e77..283d2f31e761 100644 --- a/app/models/server/models/FederationPeers.js +++ b/app/models/server/models/FederationPeers.js @@ -1,32 +1,27 @@ -import { Meteor } from 'meteor/meteor'; - import { Base } from './_Base'; - -import { Users } from '..'; +import { Users } from '../raw'; class FederationPeersModel extends Base { constructor() { super('federation_peers'); - } - refreshPeers() { - const collectionObj = this.model.rawCollection(); - const findAndModify = Meteor.wrapAsync(collectionObj.findAndModify, collectionObj); - - const users = Users.find({ federation: { $exists: true } }, { fields: { federation: 1 } }).fetch(); + this.tryEnsureIndex({ active: 1, isRemote: 1 }); + } - const peers = [...new Set(users.map((u) => u.federation.peer))]; + async refreshPeers(localIdentifier) { + const peers = await Users.getDistinctFederationPeers(); - for (const peer of peers) { - findAndModify({ peer }, [], { + peers.forEach((peer) => + this.update({ peer }, { $setOnInsert: { + isRemote: localIdentifier !== peer, active: false, peer, last_seen_at: null, last_failure_at: null, }, - }, { upsert: true }); - } + }, { upsert: true }) + ); this.remove({ peer: { $nin: peers } }); } @@ -48,6 +43,18 @@ class FederationPeersModel extends Base { this.update({ peer }, { $set: updateQuery }); } } + + findActiveRemote() { + return this.find({ active: true, isRemote: true }); + } + + findNotActiveRemote() { + return this.find({ active: false, isRemote: true }); + } + + findRemote() { + return this.find({ isRemote: true }); + } } export const FederationPeers = new FederationPeersModel(); diff --git a/app/models/server/models/Roles.js b/app/models/server/models/Roles.js index 579d15720880..dd25962cb94c 100644 --- a/app/models/server/models/Roles.js +++ b/app/models/server/models/Roles.js @@ -83,6 +83,21 @@ export class Roles extends Base { return this.findOne(query, options); } + + canAddUserToRole(uid, roleName, scope) { + const role = this.findOne({ _id: roleName }, { fields: { scope: 1 } }); + if (!role) { + return false; + } + + const model = Models[role.scope]; + if (!model) { + return; + } + + const user = model.isUserInRoleScope(uid, scope); + return !!user; + } } export default new Roles('roles'); diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index 2d7f38770932..33ddcd10a45a 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -661,13 +661,13 @@ export class Rooms extends Base { return this.find(query, options); } - findByTypes(types, options) { + findByTypes(types, discussion = false, options = {}) { const query = { t: { $in: types, }, + prid: { $exists: discussion }, }; - return this.find(query, options); } @@ -720,10 +720,11 @@ export class Rooms extends Base { return this.find(query, options); } - findByNameContaining(name, options) { + findByNameContaining(name, discussion = false, options = {}) { const nameRegex = new RegExp(s.trim(s.escapeRegExp(name)), 'i'); const query = { + prid: { $exists: discussion }, $or: [ { name: nameRegex }, { @@ -732,17 +733,17 @@ export class Rooms extends Base { }, ], }; - return this.find(query, options); } - findByNameContainingAndTypes(name, types, options) { + findByNameContainingAndTypes(name, types, discussion = false, options = {}) { const nameRegex = new RegExp(s.trim(s.escapeRegExp(name)), 'i'); const query = { t: { $in: types, }, + prid: { $exists: discussion }, $or: [ { name: nameRegex }, { @@ -751,7 +752,6 @@ export class Rooms extends Base { }, ], }; - return this.find(query, options); } diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 734370cb2da5..7149c11ec01d 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -21,6 +21,7 @@ export class Users extends Base { this.tryEnsureIndex({ type: 1 }); this.tryEnsureIndex({ 'visitorEmails.address': 1 }); this.tryEnsureIndex({ federation: 1 }, { sparse: true }); + this.tryEnsureIndex({ isRemote: 1 }, { sparse: true }); } getLoginTokensByUserId(userId) { @@ -462,6 +463,10 @@ export class Users extends Base { return this.find(query, options); } + findActive(options = {}) { + return this.find({ active: true }, options); + } + findActiveByUsernameOrNameRegexWithExceptions(searchTerm, exceptions, options) { if (exceptions == null) { exceptions = []; } if (options == null) { options = {}; } @@ -666,6 +671,14 @@ export class Users extends Base { return this.findOne(query, options); } + findRemote(options = {}) { + return this.find({ isRemote: true }, options); + } + + findActiveRemote(options = {}) { + return this.find({ active: true, isRemote: true }, options); + } + // UPDATE addImportIds(_id, importIds) { importIds = [].concat(importIds); @@ -1107,6 +1120,10 @@ Find users to send a message by email if: return this.find(query, options); } + + getActiveLocalUserCount() { + return this.findActive().count() - this.findActiveRemote().count(); + } } export default new Users(Meteor.users, true); diff --git a/app/models/server/models/_Base.js b/app/models/server/models/_Base.js index 87abf5aed684..5c68facab4ef 100644 --- a/app/models/server/models/_Base.js +++ b/app/models/server/models/_Base.js @@ -42,6 +42,20 @@ export class Base { return !_.isUndefined(this.findOne(query, { fields: { roles: 1 } })); } + isUserInRoleScope(uid, scope) { + const query = this.roleBaseQuery(uid, scope); + if (!query) { + return false; + } + + const options = { + fields: { _id: 1 }, + }; + + const found = this.findOne(query, options); + return !!found; + } + addRolesByUserId(userId, roles, scope) { roles = [].concat(roles); const query = this.roleBaseQuery(userId, scope); diff --git a/app/models/server/models/_BaseDb.js b/app/models/server/models/_BaseDb.js index cd774981ffb6..c819298f5a2c 100644 --- a/app/models/server/models/_BaseDb.js +++ b/app/models/server/models/_BaseDb.js @@ -9,13 +9,14 @@ const baseName = 'rocketchat_'; const trash = new Mongo.Collection(`${ baseName }_trash`); try { trash._ensureIndex({ collection: 1 }); - trash._ensureIndex({ _deletedAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 30 }); + trash._ensureIndex( + { _deletedAt: 1 }, + { expireAfterSeconds: 60 * 60 * 24 * 30 } + ); } catch (e) { console.log(e); } -const isOplogEnabled = MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle && !!MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle.onOplogEntry; - export class BaseDb extends EventEmitter { constructor(model, baseModel) { super(); @@ -34,24 +35,38 @@ export class BaseDb extends EventEmitter { this.wrapModel(); - let alreadyListeningToOplog = false; // When someone start listening for changes we start oplog if available - this.on('newListener', (event/* , listener*/) => { - if (event === 'change' && alreadyListeningToOplog === false) { - alreadyListeningToOplog = true; - if (isOplogEnabled) { - const query = { - collection: this.collectionName, - }; - - MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle.onOplogEntry(query, this.processOplogRecord.bind(this)); - // Meteor will handle if we have a value https://github.com/meteor/meteor/blob/5dcd0b2eb9c8bf881ffbee98bc4cb7631772c4da/packages/mongo/oplog_tailing.js#L5 - if (process.env.METEOR_OPLOG_TOO_FAR_BEHIND == null) { - MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle._defineTooFarBehind(Number.MAX_SAFE_INTEGER); - } - } + const handleListener = (event /* , listener*/) => { + if (event !== 'change') { + return; } - }); + + this.removeListener('newListener', handleListener); + + const query = { + collection: this.collectionName, + }; + + if (!MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle) { + throw new Error(`Error: Unable to find Mongodb Oplog. You must run the server with oplog enabled. Try the following:\n + 1. Start your mongodb in a replicaset mode: mongod --smallfiles --oplogSize 128 --replSet rs0\n + 2. Start the replicaset via mongodb shell: mongo mongo/meteor --eval "rs.initiate({ _id: ''rs0'', members: [ { _id: 0, host: ''localhost:27017'' } ]})"\n + 3. Start your instance with OPLOG configuration: export MONGO_OPLOG_URL=mongodb://localhost:27017/local MONGO_URL=mongodb://localhost:27017/meteor node main.js + `); + } + + MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle.onOplogEntry( + query, + this.processOplogRecord.bind(this) + ); + // Meteor will handle if we have a value https://github.com/meteor/meteor/blob/5dcd0b2eb9c8bf881ffbee98bc4cb7631772c4da/packages/mongo/oplog_tailing.js#L5 + if (process.env.METEOR_OPLOG_TOO_FAR_BEHIND == null) { + MongoInternals.defaultRemoteCollectionDriver().mongo._oplogHandle._defineTooFarBehind( + Number.MAX_SAFE_INTEGER + ); + } + }; + this.on('newListener', handleListener); this.tryEnsureIndex({ _updatedAt: 1 }); } @@ -129,7 +144,12 @@ export class BaseDb extends EventEmitter { } updateHasPositionalOperator(update) { - return Object.keys(update).some((key) => key.includes('.$') || (Match.test(update[key], Object) && this.updateHasPositionalOperator(update[key]))); + return Object.keys(update).some( + (key) => + key.includes('.$') + || (Match.test(update[key], Object) + && this.updateHasPositionalOperator(update[key])) + ); } processOplogRecord(action) { diff --git a/app/models/server/raw/BaseRaw.js b/app/models/server/raw/BaseRaw.js index 2fe2c81767b0..a2668cca3779 100644 --- a/app/models/server/raw/BaseRaw.js +++ b/app/models/server/raw/BaseRaw.js @@ -3,6 +3,10 @@ export class BaseRaw { this.col = col; } + findOneById(_id, options) { + return this.findOne({ _id }, options); + } + findOne(...args) { return this.col.findOne(...args); } diff --git a/app/models/server/raw/Rooms.js b/app/models/server/raw/Rooms.js new file mode 100644 index 000000000000..d04e5aadee3a --- /dev/null +++ b/app/models/server/raw/Rooms.js @@ -0,0 +1,26 @@ +import { BaseRaw } from './BaseRaw'; + +export class RoomsRaw extends BaseRaw { + findOneByRoomIdAndUserId(rid, uid, options) { + const query = { + rid, + 'u._id': uid, + }; + + return this.col.findOne(query, options); + } + + isUserInRole(uid, roleName, rid) { + if (rid == null) { + return; + } + + const query = { + 'u._id': uid, + rid, + roles: roleName, + }; + + return this.findOne(query, { fields: { roles: 1 } }); + } +} diff --git a/app/models/server/raw/Subscriptions.js b/app/models/server/raw/Subscriptions.js index 79a5e0f4b10d..ac7115393b41 100644 --- a/app/models/server/raw/Subscriptions.js +++ b/app/models/server/raw/Subscriptions.js @@ -1,22 +1,22 @@ import { BaseRaw } from './BaseRaw'; export class SubscriptionsRaw extends BaseRaw { - findOneByRoomIdAndUserId(roomId, userId, options) { + findOneByRoomIdAndUserId(rid, uid, options) { const query = { - rid: roomId, - 'u._id': userId, + rid, + 'u._id': uid, }; return this.col.findOne(query, options); } - isUserInRole(userId, roleName, rid) { + isUserInRole(uid, roleName, rid) { if (rid == null) { return; } const query = { - 'u._id': userId, + 'u._id': uid, rid, roles: roleName, }; diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index bc5977e2200c..95baf14b3d3c 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -19,4 +19,8 @@ export class UsersRaw extends BaseRaw { return this.findOne(query, { fields: { roles: 1 } }); } + + getDistinctFederationPeers() { + return this.col.distinct('federation.peer', { federation: { $exists: true } }); + } } diff --git a/app/models/server/raw/index.js b/app/models/server/raw/index.js index 818f2a9c1a24..9a56a1f09e21 100644 --- a/app/models/server/raw/index.js +++ b/app/models/server/raw/index.js @@ -8,9 +8,12 @@ import SettingsModel from '../models/Settings'; import { SettingsRaw } from './Settings'; import UsersModel from '../models/Users'; import { UsersRaw } from './Users'; +import RoomsModel from '../models/Rooms'; +import { RoomsRaw } from './Rooms'; export const Permissions = new PermissionsRaw(PermissionsModel.model.rawCollection()); export const Roles = new RolesRaw(RolesModel.model.rawCollection()); export const Subscriptions = new SubscriptionsRaw(SubscriptionsModel.model.rawCollection()); export const Settings = new SettingsRaw(SettingsModel.model.rawCollection()); export const Users = new UsersRaw(UsersModel.model.rawCollection()); +export const Rooms = new RoomsRaw(RoomsModel.model.rawCollection()); diff --git a/app/oauth2-server-config/client/admin/views/oauthApp.html b/app/oauth2-server-config/client/admin/views/oauthApp.html index 1696d4c3dd4e..5bcab86d702b 100644 --- a/app/oauth2-server-config/client/admin/views/oauthApp.html +++ b/app/oauth2-server-config/client/admin/views/oauthApp.html @@ -22,7 +22,7 @@ {{_ "Redirect_URI"}} - + {{data.redirectUri}} {{_ "After_OAuth2_authentication_users_will_be_redirected_to_this_URL"}} diff --git a/app/oauth2-server-config/client/admin/views/oauthApp.js b/app/oauth2-server-config/client/admin/views/oauthApp.js index 1ef9f76d3287..e0f4cc0f71c6 100644 --- a/app/oauth2-server-config/client/admin/views/oauthApp.js +++ b/app/oauth2-server-config/client/admin/views/oauthApp.js @@ -31,6 +31,10 @@ Template.oauthApp.helpers({ if (data) { data.authorization_url = Meteor.absoluteUrl('oauth/authorize'); data.access_token_url = Meteor.absoluteUrl('oauth/token'); + if (Array.isArray(data.redirectUri)) { + data.redirectUri = data.redirectUri.join('\n'); + } + Template.instance().record.set(data); return data; } diff --git a/app/oauth2-server-config/server/admin/functions/parseUriList.js b/app/oauth2-server-config/server/admin/functions/parseUriList.js new file mode 100644 index 000000000000..f00fa792f80a --- /dev/null +++ b/app/oauth2-server-config/server/admin/functions/parseUriList.js @@ -0,0 +1,17 @@ +export const parseUriList = (userUri) => { + if (userUri.indexOf('\n') < 0 && userUri.indexOf(',') < 0) { + return userUri; + } + + const uriList = []; + userUri.split(/[,\n]/).forEach((item) => { + const uri = item.trim(); + if (uri === '') { + return; + } + + uriList.push(uri); + }); + + return uriList; +}; diff --git a/app/oauth2-server-config/server/admin/methods/addOAuthApp.js b/app/oauth2-server-config/server/admin/methods/addOAuthApp.js index a5ef900f34bd..cb4d73e19f27 100644 --- a/app/oauth2-server-config/server/admin/methods/addOAuthApp.js +++ b/app/oauth2-server-config/server/admin/methods/addOAuthApp.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import { hasPermission } from '../../../../authorization'; import { Users, OAuthApps } from '../../../../models'; +import { parseUriList } from '../functions/parseUriList'; Meteor.methods({ addOAuthApp(application) { @@ -19,6 +20,13 @@ Meteor.methods({ if (!_.isBoolean(application.active)) { throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { method: 'addOAuthApp' }); } + + application.redirectUri = parseUriList(application.redirectUri); + + if (application.redirectUri.length === 0) { + throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { method: 'addOAuthApp' }); + } + application.clientId = Random.id(); application.clientSecret = Random.secret(); application._createdAt = new Date(); diff --git a/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js b/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js index 6043a34a23f0..007f5be2e95c 100644 --- a/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js +++ b/app/oauth2-server-config/server/admin/methods/updateOAuthApp.js @@ -3,6 +3,7 @@ import _ from 'underscore'; import { hasPermission } from '../../../../authorization'; import { OAuthApps, Users } from '../../../../models'; +import { parseUriList } from '../functions/parseUriList'; Meteor.methods({ updateOAuthApp(applicationId, application) { @@ -22,11 +23,18 @@ Meteor.methods({ if (currentApplication == null) { throw new Meteor.Error('error-application-not-found', 'Application not found', { method: 'updateOAuthApp' }); } + + const redirectUri = parseUriList(application.redirectUri); + + if (redirectUri.length === 0) { + throw new Meteor.Error('error-invalid-redirectUri', 'Invalid redirectUri', { method: 'updateOAuthApp' }); + } + OAuthApps.update(applicationId, { $set: { name: application.name, active: application.active, - redirectUri: application.redirectUri, + redirectUri, _updatedAt: new Date(), _updatedBy: Users.findOne(this.userId, { fields: { diff --git a/app/search/client/provider/result.js b/app/search/client/provider/result.js index aa64d32da6ba..03367888e29c 100644 --- a/app/search/client/provider/result.js +++ b/app/search/client/provider/result.js @@ -1,4 +1,5 @@ import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Session } from 'meteor/session'; @@ -39,9 +40,22 @@ Meteor.startup(function() { }); }); -Template.DefaultSearchResultTemplate.onCreated(function() { - const self = this; +Template.DefaultSearchResultTemplate.onRendered(function() { + const list = this.firstNode.parentNode.querySelector('.rocket-default-search-results'); + this.autorun(() => { + const result = this.data.result.get(); + if (result && this.hasMore.get()) { + Tracker.afterFlush(() => { + if (list.scrollHeight <= list.offsetHeight) { + this.data.payload.limit = (this.data.payload.limit || this.pageSize) + this.pageSize; + this.data.search(); + } + }); + } + }); +}); +Template.DefaultSearchResultTemplate.onCreated(function() { // paging this.pageSize = this.data.settings.PageSize; @@ -53,7 +67,7 @@ Template.DefaultSearchResultTemplate.onCreated(function() { this.autorun(() => { const result = this.data.result.get(); - self.hasMore.set(!(result && result.message.docs.length < (self.data.payload.limit || self.pageSize))); + this.hasMore.set(!(result && result.message.docs.length < (this.data.payload.limit || this.pageSize))); }); }); @@ -86,7 +100,7 @@ Template.DefaultSearchResultTemplate.helpers({ return Template.instance().hasMore.get(); }, message(msg) { - return { customClass: 'search', actionContext: 'search', ...msg }; + return { customClass: 'search', actionContext: 'search', ...msg, groupable: false }; }, messageContext, }); diff --git a/app/search/client/style/style.css b/app/search/client/style/style.css index 2b611bb6c241..93e7c4aa8d00 100644 --- a/app/search/client/style/style.css +++ b/app/search/client/style/style.css @@ -23,6 +23,7 @@ .rocket-search-result { display: flex; + overflow: hidden; flex-direction: column; flex: 1; } diff --git a/app/search/server/events/events.js b/app/search/server/events/events.js index 82fac3527e87..79e3c8aa8142 100644 --- a/app/search/server/events/events.js +++ b/app/search/server/events/events.js @@ -24,11 +24,11 @@ const eventService = new EventService(); */ callbacks.add('afterSaveMessage', function(m) { eventService.promoteEvent('message.save', m._id, m); -}); +}, callbacks.priority.MEDIUM, 'search-events'); callbacks.add('afterDeleteMessage', function(m) { eventService.promoteEvent('message.delete', m._id); -}); +}, callbacks.priority.MEDIUM, 'search-events-delete'); /** * Listen to user and room changes via cursor diff --git a/app/setup-wizard/client/setupWizard.js b/app/setup-wizard/client/setupWizard.js index 9cebce0ebed9..f07f58b0ca84 100644 --- a/app/setup-wizard/client/setupWizard.js +++ b/app/setup-wizard/client/setupWizard.js @@ -12,6 +12,7 @@ import { callbacks } from '../../callbacks'; import { hasRole } from '../../authorization'; import { Users } from '../../models'; import { t, handleError } from '../../utils'; +import { call } from '../../ui-utils'; const cannotSetup = () => { const showSetupWizard = settings.get('Show_Setup_Wizard'); @@ -100,26 +101,43 @@ const persistSettings = (state, callback) => { }); }; -Template.setupWizard.onCreated(function() { - this.state = new ReactiveDict(); - this.state.set('currentStep', 1); - this.state.set('registerServer', true); - this.state.set('optIn', true); +Template.setupWizard.onCreated(async function() { + const statusDefault = { + currentStep: 1, + registerServer: true, + optIn: true, + }; + this.state = new ReactiveDict(statusDefault); this.wizardSettings = new ReactiveVar([]); this.allowStandaloneServer = new ReactiveVar(false); if (localStorage.getItem('wizardFinal')) { - FlowRouter.go('setup-wizard-final'); - return; + return FlowRouter.go('setup-wizard-final'); } const jsonString = localStorage.getItem('wizard'); - const state = (jsonString && JSON.parse(jsonString)) || {}; - Object.entries(state).forEach((entry) => this.state.set(...entry)); + const state = (jsonString && JSON.parse(jsonString)) || statusDefault; + this.state.set(state); + + this.autorun(async () => { + if (!Meteor.userId()) { + return; + } + const { settings, allowStandaloneServer } = await call('getSetupWizardParameters') || {}; + this.wizardSettings.set(settings); + this.allowStandaloneServer.set(allowStandaloneServer); + }); - this.autorun((c) => { + this.autorun(() => { + const state = this.state.all(); + state['registration-pass'] = ''; + localStorage.setItem('wizard', JSON.stringify(state)); + }); + + this.autorun(async (c) => { const cantSetup = cannotSetup(); + if (typeof cantSetup === 'undefined') { return; } @@ -130,27 +148,14 @@ Template.setupWizard.onCreated(function() { return; } - const state = this.state.all(); - state['registration-pass'] = ''; - localStorage.setItem('wizard', JSON.stringify(state)); - - if (Meteor.userId()) { - Meteor.call('getSetupWizardParameters', (error, { settings, allowStandaloneServer }) => { - if (error) { - return handleError(error); - } - - this.wizardSettings.set(settings); - this.allowStandaloneServer.set(allowStandaloneServer); - }); + if (!Meteor.userId()) { + return this.state.set('currentStep', 1); + } - if (this.state.get('currentStep') === 1) { - this.state.set('currentStep', 2); - } else { - this.state.set('registration-pass', ''); - } - } else if (this.state.get('currentStep') !== 1) { - this.state.set('currentStep', 1); + if (this.state.get('currentStep') === 1) { + this.state.set('currentStep', 2); + } else { + this.state.set('registration-pass', ''); } }); }); diff --git a/app/statistics/server/functions/get.js b/app/statistics/server/functions/get.js index 4b4898a2c9ac..32c8801827b3 100644 --- a/app/statistics/server/functions/get.js +++ b/app/statistics/server/functions/get.js @@ -13,23 +13,22 @@ import { Uploads, Messages, LivechatVisitors, + Integrations, } from '../../../models/server'; import { settings } from '../../../settings/server'; import { Info, getMongoInfo } from '../../../utils/server'; import { Migrations } from '../../../migrations/server'; import { statistics } from '../statisticsNamespace'; +import { Apps } from '../../../apps/server'; +import { getStatistics as federationGetStatistics } from '../../../federation/server/methods/dashboard'; const wizardFields = [ 'Organization_Type', - 'Organization_Name', 'Industry', 'Size', 'Country', - 'Website', - 'Site_Name', 'Language', 'Server_Type', - 'Allow_Marketing_Emails', 'Register_Server', ]; @@ -46,14 +45,6 @@ statistics.get = function _getStatistics() { } }); - const firstUser = Users.getOldest({ name: 1, emails: 1 }); - statistics.wizard.contactName = firstUser && firstUser.name; - statistics.wizard.contactEmail = firstUser && firstUser.emails && firstUser.emails[0].address; - - if (settings.get('Organization_Email')) { - statistics.wizard.contactEmail = settings.get('Organization_Email'); - } - // Version statistics.uniqueId = settings.get('uniqueID'); if (Settings.findOne('uniqueID')) { @@ -100,6 +91,13 @@ statistics.get = function _getStatistics() { statistics.totalDirectMessages = _.reduce(Rooms.findByType('d', { fields: { msgs: 1 } }).fetch(), function _countDirectMessages(num, room) { return num + room.msgs; }, 0); statistics.totalLivechatMessages = _.reduce(Rooms.findByType('l', { fields: { msgs: 1 } }).fetch(), function _countLivechatMessages(num, room) { return num + room.msgs; }, 0); + // Federation statistics + const federationOverviewData = federationGetStatistics(); + + statistics.federatedServers = federationOverviewData.numberOfActivePeers + federationOverviewData.numberOfInactivePeers; + statistics.federatedServersActive = federationOverviewData.numberOfActivePeers; + statistics.federatedUsers = federationOverviewData.numberOfFederatedUsers; + statistics.lastLogin = Users.getLastLogin(); statistics.lastMessageSentAt = Messages.getLastTimestamp(); statistics.lastSeenSubscription = Subscriptions.getLastSeen(); @@ -146,5 +144,23 @@ statistics.get = function _getStatistics() { statistics.uniqueOSOfYesterday = Sessions.getUniqueOSOfYesterday(); statistics.uniqueOSOfLastMonth = Sessions.getUniqueOSOfLastMonth(); + statistics.apps = { + engineVersion: Info.marketplaceApiVersion, + enabled: Apps && Apps.isEnabled(), + totalInstalled: Apps && Apps.getManager().get().length, + totalActive: Apps && Apps.getManager().get({ enabled: true }).length, + }; + + const integrations = Integrations.find().fetch(); + + statistics.integrations = { + totalIntegrations: integrations.length, + totalIncoming: integrations.filter((integration) => integration.type === 'webhook-incoming').length, + totalIncomingActive: integrations.filter((integration) => integration.enabled === true && integration.type === 'webhook-incoming').length, + totalOutgoing: integrations.filter((integration) => integration.type === 'webhook-outgoing').length, + totalOutgoingActive: integrations.filter((integration) => integration.enabled === true && integration.type === 'webhook-outgoing').length, + totalWithScriptEnabled: integrations.filter((integration) => integration.scriptEnabled === true).length, + }; + return statistics; }; diff --git a/app/theme/client/imports/components/contextual-bar.css b/app/theme/client/imports/components/contextual-bar.css index 81659afb9a24..9c53099942d4 100644 --- a/app/theme/client/imports/components/contextual-bar.css +++ b/app/theme/client/imports/components/contextual-bar.css @@ -163,123 +163,125 @@ } } -.attachments { - &__item { - position: relative; +.flex-tab__result { + .attachments { + &__item { + position: relative; - margin-bottom: 10px; + margin-bottom: 10px; - transition: background-color 300ms linear; + transition: background-color 300ms linear; - &.active, - &:hover { - cursor: pointer; + &.active, + &:hover { + cursor: pointer; - background-color: #f7f8fa; + background-color: #f7f8fa; - .attachments-menu { - display: inline-block; + .attachments-menu { + display: inline-block; + } } - } - &-link { - display: flex; - flex-direction: row; + &-link { + display: flex; + flex-direction: row; - padding: 8px 0; - align-items: center; + padding: 8px 0; + align-items: center; + } } - } - &__file, - &__thumb { - display: inline-block; - display: flex; - flex-direction: column; - flex: 0 0 auto; + &__file, + &__thumb { + display: inline-block; + display: flex; + flex-direction: column; + flex: 0 0 auto; - width: 50px; + width: 50px; - height: 50px; - margin: 0 8px; + height: 50px; + margin: 0 8px; - border-radius: 2px; + border-radius: 2px; - background: radial-gradient(ellipse at center, rgba(155, 169, 186, 1) 0%, rgba(131, 143, 158, 1) 100%); - background-size: cover; + background: radial-gradient(ellipse at center, rgba(155, 169, 186, 1) 0%, rgba(131, 143, 158, 1) 100%); + background-size: cover; - font-size: 24px; + font-size: 24px; - align-items: center; - justify-content: center; - } - - &__file { - &--pdf { - background: radial-gradient(ellipse at center, rgba(250, 97, 97, 1) 0%, rgba(251, 19, 19, 1) 100%); + align-items: center; + justify-content: center; } - &--sheets { - background: radial-gradient(ellipse at center, rgba(0, 163, 82, 1) 0%, rgba(2, 114, 59, 1) 100%); - } + &__file { + &--pdf { + background: radial-gradient(ellipse at center, rgba(250, 97, 97, 1) 0%, rgba(251, 19, 19, 1) 100%); + } - &--ppt { - background: radial-gradient(ellipse at center, rgba(250, 109, 77, 1) 0%, rgba(208, 71, 40, 1) 100%); + &--sheets { + background: radial-gradient(ellipse at center, rgba(0, 163, 82, 1) 0%, rgba(2, 114, 59, 1) 100%); + } + + &--ppt { + background: radial-gradient(ellipse at center, rgba(250, 109, 77, 1) 0%, rgba(208, 71, 40, 1) 100%); + } } - } - &__type { - overflow: hidden; - flex: 0 0 auto; + &__type { + overflow: hidden; + flex: 0 0 auto; - max-width: 100%; - padding: 0 10px; + max-width: 100%; + padding: 0 10px; - text-overflow: ellipsis; + text-overflow: ellipsis; - color: white; + color: white; - font-size: 10px; - } + font-size: 10px; + } - &__name { + &__name { - overflow: hidden; + overflow: hidden; - min-width: 0; - margin: 0 8px; + min-width: 0; + margin: 0 8px; - white-space: nowrap; - text-overflow: ellipsis; + white-space: nowrap; + text-overflow: ellipsis; - color: #2f343d; + color: #2f343d; - font-size: 14px; - line-height: 1.5; - } + font-size: 14px; + line-height: 1.5; + } - &__details { - margin: 0 8px 2px; + &__details { + margin: 0 8px 2px; - white-space: nowrap; - text-overflow: ellipsis; + white-space: nowrap; + text-overflow: ellipsis; - font-size: 12px; - } + font-size: 12px; + } - &__bold { - font-weight: 600; - } + &__bold { + font-weight: 600; + } - &__content { - display: flex; + &__content { + display: flex; - overflow: hidden; - flex-direction: column; + overflow: hidden; + flex-direction: column; - flex: 1 1 100%; + flex: 1 1 100%; - color: #9ea2a8; + color: #9ea2a8; + } } } diff --git a/app/theme/client/imports/components/tabs.css b/app/theme/client/imports/components/tabs.css index cf7a87448cd2..187394482509 100644 --- a/app/theme/client/imports/components/tabs.css +++ b/app/theme/client/imports/components/tabs.css @@ -13,8 +13,9 @@ } .tab { + display: flex; + margin: 0 1rem; - padding: 1rem 0; cursor: pointer; @@ -24,13 +25,25 @@ border-bottom: 2px solid transparent; + font-family: inherit; font-size: 1rem; - font-weight: 500; line-height: 1.25rem; + align-items: stretch; + flex-flow: row nowrap; &.active { color: var(--rc-color-button-primary); border-bottom-color: var(--rc-color-button-primary); } + + &:focus { + text-decoration: underline; + } + + & > span { + flex: 1; + + padding: 1rem 0; + } } diff --git a/app/theme/client/imports/general/base.css b/app/theme/client/imports/general/base.css index 4c5467746743..f7b8eb0bc8f4 100644 --- a/app/theme/client/imports/general/base.css +++ b/app/theme/client/imports/general/base.css @@ -219,56 +219,3 @@ button { .hidden { display: none; } - -.loading-animation { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - - display: flex; - - text-align: center; - align-items: center; - justify-content: center; -} - -.loading-animation > .bounce { - display: inline-block; - - width: 10px; - height: 10px; - margin: 2px; - - animation: loading-bouncedelay 1.4s infinite ease-in-out both; - - border-radius: 100%; - background-color: rgba(255, 255, 255, 0.6); -} - -.loading-animation .bounce1 { - -webkit-animation-delay: -0.32s; - animation-delay: -0.32s; -} - -.loading-animation .bounce2 { - -webkit-animation-delay: -0.16s; - animation-delay: -0.16s; -} - -.file-picker-loading .loading-animation > .bounce { - background-color: #444444; -} - -@keyframes loading-bouncedelay { - 0%, - 80%, - 100% { - transform: scale(0); - } - - 40% { - transform: scale(1); - } -} diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index bb298b0566c5..5c4c10743925 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -4951,6 +4951,12 @@ rc-old select, } } +.rc-old .load-more { + position: relative; + + height: 2rem; +} + .rc-old .flex-tab { &__content { display: flex; diff --git a/app/theme/client/imports/general/forms.css b/app/theme/client/imports/general/forms.css index 323905dd2e6a..45a0d9ad2474 100644 --- a/app/theme/client/imports/general/forms.css +++ b/app/theme/client/imports/general/forms.css @@ -253,7 +253,7 @@ height: 100%; &--new { - padding: 0 1.5rem; + padding: 1.5rem; } &__content { diff --git a/app/ui-account/client/accountPreferences.js b/app/ui-account/client/accountPreferences.js index 417092de21cb..cd4058b3664d 100644 --- a/app/ui-account/client/accountPreferences.js +++ b/app/ui-account/client/accountPreferences.js @@ -235,8 +235,9 @@ Template.accountPreferences.onCreated(function() { if (results.requested) { modal.open({ title: t('UserDataDownload_Requested'), - text: t('UserDataDownload_Requested_Text'), + text: t('UserDataDownload_Requested_Text', { pending_operations: results.pendingOperationsBeforeMyRequest }), type: 'success', + html: true, }); return true; @@ -260,8 +261,9 @@ Template.accountPreferences.onCreated(function() { modal.open({ title: t('UserDataDownload_Requested'), - text: t('UserDataDownload_RequestExisted_Text'), + text: t('UserDataDownload_RequestExisted_Text', { pending_operations: results.pendingOperationsBeforeMyRequest }), type: 'success', + html: true, }); return true; } diff --git a/app/ui-admin/client/adminInfo.html b/app/ui-admin/client/adminInfo.html index 7e9b701fea31..faf17567adec 100644 --- a/app/ui-admin/client/adminInfo.html +++ b/app/ui-admin/client/adminInfo.html @@ -254,7 +254,40 @@ {{_ "Usage"}} {{_ "Stats_Total_Uploads_Size"}} {{inGB statistics.uploadsTotalSize}} - + {{#if statistics.apps}} + + {{_ "Stats_Total_Installed_Apps"}} + {{statistics.apps.totalInstalled}} + + + {{_ "Stats_Total_Active_Apps"}} + {{statistics.apps.totalActive}} + + {{/if}} + + {{_ "Stats_Total_Integrations"}} + {{statistics.integrations.totalIntegrations}} + + + {{_ "Stats_Total_Incoming_Integrations"}} + {{statistics.integrations.totalIncoming}} + + + {{_ "Stats_Total_Active_Incoming_Integrations"}} + {{statistics.integrations.totalIncomingActive}} + + + {{_ "Stats_Total_Outgoing_Integrations"}} + {{statistics.integrations.totalOutgoing}} + + + {{_ "Stats_Total_Active_Outgoing_Integrations"}} + {{statistics.integrations.totalOutgoingActive}} + + + {{_ "Stats_Total_Integrations_With_Script_Enabled"}} + {{statistics.integrations.totalWithScriptEnabled}} + {{#if instances}} diff --git a/app/ui-admin/client/rooms/adminRooms.html b/app/ui-admin/client/rooms/adminRooms.html index 722e8abc5000..d43334fae9f7 100644 --- a/app/ui-admin/client/rooms/adminRooms.html +++ b/app/ui-admin/client/rooms/adminRooms.html @@ -21,6 +21,8 @@ {{_ "Channels"}} {{_ "Direct_Messages"}} {{_ "Private_Groups"}} + {{_ "Livechat"}} + {{_ "Discussions"}} {{{_ "Showing_results" roomCount}}} @@ -39,10 +41,10 @@ {{#each rooms}} - {{> avatar username=name roomIcon="true"}} + {{> avatar url=url roomIcon="true"}} - {{>icon icon="hashtag" block="rc-table-icon"}} {{name}} + {{>icon icon=getIcon block="rc-table-icon"}} {{roomName}} diff --git a/app/ui-admin/client/rooms/adminRooms.js b/app/ui-admin/client/rooms/adminRooms.js index e757c47d05e7..989417a72908 100644 --- a/app/ui-admin/client/rooms/adminRooms.js +++ b/app/ui-admin/client/rooms/adminRooms.js @@ -16,6 +16,15 @@ import { ChannelSettings } from '../../../channel-settings'; export const AdminChatRoom = new Mongo.Collection('rocketchat_room'); Template.adminRooms.helpers({ + url() { + return roomTypes.getConfig(this.t).getAvatarPath(this); + }, + getIcon() { + return roomTypes.getIcon(this); + }, + roomName() { + return roomTypes.getRoomName(this.t, this); + }, searchText() { const instance = Template.instance(); return instance.filter && instance.filter.get(); @@ -110,11 +119,12 @@ Template.adminRooms.onCreated(function() { return hasAllPermission('view-room-administration'); }, }); + const allowedTypes = ['c', 'd', 'p']; this.autorun(function() { const filter = instance.filter.get(); let types = instance.types.get(); if (types.length === 0) { - types = ['c', 'd', 'p']; + types = allowedTypes; } const limit = instance.limit.get(); const subscription = instance.subscribe('adminRooms', filter, types, limit); @@ -130,14 +140,18 @@ Template.adminRooms.onCreated(function() { types = []; } let query = {}; + const discussion = types.includes('dicussions'); filter = s.trim(filter); if (filter) { const filterReg = new RegExp(s.escapeRegExp(filter), 'i'); - query = { $or: [{ name: filterReg }, { t: 'd', usernames: filterReg }] }; + query = { ...discussion && { prid: { $exists: true } }, $or: [{ name: filterReg }, { t: 'd', usernames: filterReg }] }; } + types = types.filter((type) => type !== 'dicussions'); + if (types.length) { query.t = { $in: types }; } + const limit = instance.limit && instance.limit.get(); return AdminChatRoom.find(query, { limit, sort: { default: -1, name: 1 } }); }; diff --git a/app/ui-admin/server/publications/adminRooms.js b/app/ui-admin/server/publications/adminRooms.js index bd8971e6df4d..326e3530649f 100644 --- a/app/ui-admin/server/publications/adminRooms.js +++ b/app/ui-admin/server/publications/adminRooms.js @@ -1,23 +1,25 @@ import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; import s from 'underscore.string'; import { hasPermission } from '../../../authorization'; -import { Rooms } from '../../../models'; +import { Rooms } from '../../../models/server'; + +Meteor.publish('adminRooms', function(filter, types = [], limit) { + const showTypes = Array.isArray(types) ? types.filter((type) => type !== 'dicussions') : []; + const discussion = types.includes('dicussions'); -Meteor.publish('adminRooms', function(filter, types, limit) { if (!this.userId) { return this.ready(); } + if (hasPermission(this.userId, 'view-room-administration') !== true) { return this.ready(); } - if (!_.isArray(types)) { - types = []; - } const options = { fields: { + prid: 1, + fname: 1, name: 1, t: 1, cl: 1, @@ -40,14 +42,17 @@ Meteor.publish('adminRooms', function(filter, types, limit) { }, }; - filter = s.trim(filter); - if (filter && types.length) { + const name = s.trim(filter); + + if (name && showTypes.length) { // CACHE: can we stop using publications here? - return Rooms.findByNameContainingAndTypes(filter, types, options); - } if (types.length) { + return Rooms.findByNameContainingAndTypes(name, showTypes, discussion, options); + } + + if (showTypes.length) { // CACHE: can we stop using publications here? - return Rooms.findByTypes(types, options); + return Rooms.findByTypes(showTypes, discussion, options); } // CACHE: can we stop using publications here? - return Rooms.findByNameContaining(filter, options); + return Rooms.findByNameContaining(filter, discussion, options); }); diff --git a/app/ui-flextab/client/tabs/inviteUsers.js b/app/ui-flextab/client/tabs/inviteUsers.js index d4f1cc8bdfe2..35042d4d0afd 100644 --- a/app/ui-flextab/client/tabs/inviteUsers.js +++ b/app/ui-flextab/client/tabs/inviteUsers.js @@ -137,25 +137,28 @@ Template.inviteUsers.onCreated(function() { this.ac = new AutoComplete({ selector: { + anchor: '.rc-input__label', item: '.rc-popup-list__item', container: '.rc-popup-list__list', }, position: 'fixed', limit: 10, inputDelay: 300, - rules: [{ - // @TODO maybe change this 'collection' and/or template - collection: 'UserAndRoom', - subscription: 'userAutocomplete', - field: 'username', - matchAll: true, - filter, - doNotChangeWidth: false, - selector(match) { - return { term: match }; + rules: [ + { + // @TODO maybe change this 'collection' and/or template + collection: 'UserAndRoom', + subscription: 'userAutocomplete', + field: 'username', + matchAll: true, + filter, + doNotChangeWidth: false, + selector(match) { + return { term: match }; + }, + sort: 'username', }, - sort: 'username', - }], + ], }); this.ac.tmplInst = this; }); diff --git a/app/ui-flextab/client/tabs/uploadedFilesList.html b/app/ui-flextab/client/tabs/uploadedFilesList.html index ad09ca8819dc..3da55f8603fb 100644 --- a/app/ui-flextab/client/tabs/uploadedFilesList.html +++ b/app/ui-flextab/client/tabs/uploadedFilesList.html @@ -12,7 +12,6 @@ - {{#each files}} @@ -39,19 +38,17 @@ {{> icon file=. block="attachments-menu js-action" icon="menu"}} - + {{else}} + {{#if Template.subscriptionsReady}} + {{_ "Room_uploaded_file_list_empty"}} + {{/if}} {{/each}} - {{#if hasMore}} + {{#if isLoading}} {{> loading}} {{/if}} - {{#if Template.subscriptionsReady}} - {{#unless hasFiles}} - {{_ "Room_uploaded_file_list_empty"}} - {{/unless}} - {{/if}} diff --git a/app/ui-flextab/client/tabs/uploadedFilesList.js b/app/ui-flextab/client/tabs/uploadedFilesList.js index 9f65c18d2ad9..abbacb4556db 100644 --- a/app/ui-flextab/client/tabs/uploadedFilesList.js +++ b/app/ui-flextab/client/tabs/uploadedFilesList.js @@ -3,6 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { Template } from 'meteor/templating'; import { Mongo } from 'meteor/mongo'; import { ReactiveVar } from 'meteor/reactive-var'; +import { ReactiveDict } from 'meteor/reactive-dict'; import { DateFormat } from '../../../lib/client'; import { canDeleteMessage, getURL, handleError, t } from '../../../utils/client'; @@ -10,18 +11,20 @@ import { popover, modal } from '../../../ui-utils/client'; const roomFiles = new Mongo.Collection('room_files'); +const LIST_SIZE = 50; + Template.uploadedFilesList.onCreated(function() { const { rid } = Template.currentData(); this.searchText = new ReactiveVar(null); - this.hasMore = new ReactiveVar(true); - this.limit = new ReactiveVar(50); + + this.state = new ReactiveDict({ + limit: LIST_SIZE, + hasMore: true, + }); this.autorun(() => { - this.subscribe('roomFilesWithSearchText', rid, this.searchText.get(), this.limit.get(), () => { - if (roomFiles.find({ rid }).fetch().length < this.limit.get()) { - this.hasMore.set(false); - } - }); + const ready = this.subscribe('roomFilesWithSearchText', rid, this.searchText.get(), this.state.get('limit'), () => this.state.set('hasMore', this.state.get('limit') <= roomFiles.find({ rid }).count())).ready(); + this.state.set('loading', !ready); }); }); @@ -46,6 +49,9 @@ Template.uploadedFilesList.helpers({ return getURL(this.url); } }, + limit() { + return Template.instance().state.get('limit'); + }, format(timestamp) { return DateFormat.formatDateAndTime(timestamp); }, @@ -96,12 +102,8 @@ Template.uploadedFilesList.helpers({ return DateFormat.formatDateAndTime(timestamp); }, - hasMore() { - return Template.instance().hasMore.get(); - }, - - hasFiles() { - return roomFiles.find({ rid: this.rid }).count() > 0; + isLoading() { + return Template.instance().state.get('loading'); }, }); @@ -112,12 +114,15 @@ Template.uploadedFilesList.events({ 'input .uploaded-files-list__search-input'(e, t) { t.searchText.set(e.target.value.trim()); - t.hasMore.set(true); + t.state.set('hasMore', true); }, 'scroll .flex-tab__result': _.throttle(function(e, t) { if (e.target.scrollTop >= (e.target.scrollHeight - e.target.clientHeight)) { - return t.limit.set(t.limit.get() + 50); + if (!t.state.get('hasMore')) { + return; + } + return t.state.set('limit', t.state.get('limit') + LIST_SIZE); } }, 200), diff --git a/app/ui-master/client/index.js b/app/ui-master/client/index.js index b5870d194100..67abd78bcfe5 100644 --- a/app/ui-master/client/index.js +++ b/app/ui-master/client/index.js @@ -1,4 +1,4 @@ -import './loading.html'; +import './loading'; import './error.html'; import './logoLayout.html'; import './main.html'; diff --git a/app/ui-master/client/loading/index.js b/app/ui-master/client/loading/index.js new file mode 100644 index 000000000000..66a769be942f --- /dev/null +++ b/app/ui-master/client/loading/index.js @@ -0,0 +1,2 @@ +import './loading.css'; +import './loading.html'; diff --git a/app/ui-master/client/loading/loading.css b/app/ui-master/client/loading/loading.css new file mode 100644 index 000000000000..29ab7c8125e7 --- /dev/null +++ b/app/ui-master/client/loading/loading.css @@ -0,0 +1,52 @@ +.loading-animation { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + display: flex; + + text-align: center; + align-items: center; + justify-content: center; +} + +.loading-animation > .bounce { + display: inline-block; + + width: 10px; + height: 10px; + margin: 2px; + + animation: loading-bouncedelay 1.4s infinite ease-in-out both; + + border-radius: 100%; + background-color: rgba(255, 255, 255, 0.6); +} + +.loading-animation .bounce1 { + -webkit-animation-delay: -0.32s; + animation-delay: -0.32s; +} + +.loading-animation .bounce2 { + -webkit-animation-delay: -0.16s; + animation-delay: -0.16s; +} + +.file-picker-loading .loading-animation > .bounce { + background-color: #444444; +} + +@keyframes loading-bouncedelay { + 0%, + 80%, + 100% { + transform: scale(0); + } + + 40% { + transform: scale(1); + } +} diff --git a/app/ui-master/client/loading.html b/app/ui-master/client/loading/loading.html similarity index 100% rename from app/ui-master/client/loading.html rename to app/ui-master/client/loading/loading.html diff --git a/app/ui-master/public/icons/card.svg b/app/ui-master/public/icons/card.svg new file mode 100644 index 000000000000..904200b2b4ed --- /dev/null +++ b/app/ui-master/public/icons/card.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/ui-message/client/message.html b/app/ui-message/client/message.html index a155f1697046..5e80a3329fce 100644 --- a/app/ui-message/client/message.html +++ b/app/ui-message/client/message.html @@ -27,6 +27,7 @@ {{getName}}{{#if showUsername}} @{{msg.u.username}}{{/if}} + {{#each role in roleTags}} {{role.description}} {{/each}} diff --git a/app/ui-message/client/message.js b/app/ui-message/client/message.js index b0fcd419455a..0bc7739bbc9e 100644 --- a/app/ui-message/client/message.js +++ b/app/ui-message/client/message.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import s from 'underscore.string'; import { Blaze } from 'meteor/blaze'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; @@ -78,6 +79,7 @@ const renderBody = (msg, settings) => { } else if (messageType.template) { // render template } else if (messageType.message) { + msg.msg = s.escapeHTML(msg.msg); msg = TAPi18n.__(messageType.message, { ...typeof messageType.data === 'function' && messageType.data(msg) }); } else if (msg.u && msg.u.username === settings.Chatops_Username) { msg.html = msg.msg; @@ -204,6 +206,10 @@ Template.message.helpers({ return 'own'; } }, + t() { + const { msg } = this; + return msg.t; + }, timestamp() { const { msg } = this; return +msg.ts; diff --git a/app/ui-utils/client/lib/MessageAction.js b/app/ui-utils/client/lib/MessageAction.js index e196985727dd..b35e04f887ef 100644 --- a/app/ui-utils/client/lib/MessageAction.js +++ b/app/ui-utils/client/lib/MessageAction.js @@ -335,7 +335,7 @@ Meteor.startup(async function() { modal.open({ title: TAPi18n.__('Report_sent'), - text: TAPi18n.__('Thank_you_exclamation_mark '), + text: TAPi18n.__('Thank_you_exclamation_mark'), type: 'success', timer: 1000, showConfirmButton: false, diff --git a/app/ui-utils/client/lib/RoomHistoryManager.js b/app/ui-utils/client/lib/RoomHistoryManager.js index b6d0db8beb9e..2010ecb519a8 100644 --- a/app/ui-utils/client/lib/RoomHistoryManager.js +++ b/app/ui-utils/client/lib/RoomHistoryManager.js @@ -160,11 +160,13 @@ export const RoomHistoryManager = new class { } if (wrapper) { - if (wrapper.scrollHeight <= wrapper.offsetHeight) { - return this.getMore(rid); - } - const heightDiff = wrapper.scrollHeight - previousHeight; - wrapper.scrollTop += heightDiff; + Tracker.afterFlush(() => { + if (wrapper.scrollHeight <= wrapper.offsetHeight) { + return this.getMore(rid); + } + const heightDiff = wrapper.scrollHeight - previousHeight; + wrapper.scrollTop += heightDiff; + }); } room.isLoading.set(false); diff --git a/app/ui-utils/client/lib/modal.html b/app/ui-utils/client/lib/modal.html index d31f2fd32147..7f3274cfd419 100644 --- a/app/ui-utils/client/lib/modal.html +++ b/app/ui-utils/client/lib/modal.html @@ -53,7 +53,7 @@ {{# if showFooter }} {{/if}} diff --git a/app/ui/client/components/icon.js b/app/ui/client/components/icon.js index 779ea70f1632..6e759b01b17c 100644 --- a/app/ui/client/components/icon.js +++ b/app/ui/client/components/icon.js @@ -1,10 +1,23 @@ import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; -import { isChrome, isFirefox } from '../../../utils'; +import './icon.html'; + const baseUrlFix = () => `${ document.baseURI }${ FlowRouter.current().path.substring(1) }`; +const isMozillaFirefoxBelowVersion = (upperVersion) => { + const [, version] = navigator.userAgent.match(/Firefox\/(\d+)\.\d/) || []; + return parseInt(version, 10) < upperVersion; +}; + +const isGoogleChromeBelowVersion = (upperVersion) => { + const [, version] = navigator.userAgent.match(/Chrome\/(\d+)\.\d/) || []; + return parseInt(version, 10) < upperVersion; +}; + +const isBaseUrlFixNeeded = () => isMozillaFirefoxBelowVersion(55) || isGoogleChromeBelowVersion(55); + Template.icon.helpers({ - baseUrl: (isFirefox && isFirefox[1] < 55) || (isChrome && isChrome[1] < 55) ? baseUrlFix : undefined, + baseUrl: isBaseUrlFixNeeded() ? baseUrlFix : undefined, }); diff --git a/app/ui/client/components/tabs.html b/app/ui/client/components/tabs.html index 37ec16ef2270..5ed7ec7395d5 100644 --- a/app/ui/client/components/tabs.html +++ b/app/ui/client/components/tabs.html @@ -1,8 +1,15 @@ - - {{#each tabs}} - {{label}} + + {{#each tab in tabs}} + + {{tab.label}} + {{/each}} diff --git a/app/ui/client/components/tabs.js b/app/ui/client/components/tabs.js index ab49fa82d191..eb429fb4f76d 100644 --- a/app/ui/client/components/tabs.js +++ b/app/ui/client/components/tabs.js @@ -1,18 +1,21 @@ import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; +import './tabs.html'; + + Template.tabs.onCreated(function() { this.activeTab = new ReactiveVar(this.data.tabs.tabs.find((tab) => tab.active).value); }); Template.tabs.events({ - 'click .tab'(e) { - const { value } = e.currentTarget.dataset; - if (value === Template.instance().activeTab.get()) { + 'click .tab'(event, instance) { + const { value } = event.currentTarget.dataset; + if (value === instance.activeTab.get()) { return; } - Template.instance().activeTab.set(value); - Template.instance().data.tabs.onChange(value); + instance.activeTab.set(value); + instance.data.tabs.onChange(value); }, }); @@ -23,4 +26,7 @@ Template.tabs.helpers({ isActive(value) { return Template.instance().activeTab.get() === value; }, + ariaSelected(value) { + return Template.instance().activeTab.get() === value ? { 'aria-selected': 'true' } : {}; + }, }); diff --git a/app/ui/client/index.js b/app/ui/client/index.js index aafc4dc9cbfd..89e709dfe536 100644 --- a/app/ui/client/index.js +++ b/app/ui/client/index.js @@ -44,11 +44,9 @@ import './views/app/secretURL'; import './views/app/videoCall/videoButtons'; import './views/app/videoCall/videoCall'; import './views/app/photoswipe'; -import './components/icon.html'; import './components/icon'; import './components/table.html'; import './components/table'; -import './components/tabs.html'; import './components/tabs'; import './components/popupList.html'; import './components/popupList'; diff --git a/app/ui/client/lib/chatMessages.js b/app/ui/client/lib/chatMessages.js index eb2422eb1f16..b96f9d432256 100644 --- a/app/ui/client/lib/chatMessages.js +++ b/app/ui/client/lib/chatMessages.js @@ -138,18 +138,22 @@ export class ChatMessages { toPrevMessage() { const { element } = this.editing; - if (element) { - let previous; - for (previous = element.previousElementSibling; previous; previous = previous.previousElementSibling) { - if (previous.matches('.own:not(.system)')) { - break; - } + if (!element) { + const messages = Array.from(this.wrapper.querySelectorAll('.own:not(.system)')); + const message = messages.pop(); + return message && this.edit(message, false); + } + + for ( + let previous = element.previousElementSibling; + previous; + previous = previous.previousElementSibling + ) { + if (previous.matches('.own:not(.system)')) { + return this.edit(previous, false); } - - previous ? this.edit(previous, false) : this.clearEditing(); - } else { - this.edit(this.wrapper.querySelector('.own:not(.system):last-child'), false); } + this.clearEditing(); } toNextMessage() { diff --git a/app/ui/client/lib/recorderjs/videoRecorder.js b/app/ui/client/lib/recorderjs/videoRecorder.js index 0c09d44ecc48..8b3a92c46f9a 100644 --- a/app/ui/client/lib/recorderjs/videoRecorder.js +++ b/app/ui/client/lib/recorderjs/videoRecorder.js @@ -62,6 +62,7 @@ export const VideoRecorder = new class VideoRecorder { this.videoel.src = URL.createObjectURL(stream); } + this.videoel.muted = true; this.videoel.onloadedmetadata = () => { this.videoel && this.videoel.play(); }; diff --git a/app/ui/client/views/app/room.html b/app/ui/client/views/app/room.html index a676a96d5356..7dce04f57ed3 100644 --- a/app/ui/client/views/app/room.html +++ b/app/ui/client/views/app/room.html @@ -87,7 +87,7 @@ {{name}} - {{_ statusDisplay}} + {{statusDisplay}} {{_ "Chat_Now"}} @@ -144,13 +144,6 @@ {{/if}} - {{#if hasMoreNext}} - - {{#if isLoading}} - {{> loading}} - {{/if}} - - {{/if}}
{{theError}}
{{error}}