Regression: Apps and Marketplace UI issues (#15045)

* Alert admins about apps on invalid state

* Implement ui for warning and error alerts in apps

* Open detail modal on viewing subscription info instead of the subscribe one.  Check license after close

* Implement ui for failed state of apps in detail screen

* Add failure alert support into appManage

* Show validation erros/warnings on app detail page

* Add status column to apps template

* Update uninstall modal

* Notify admins of disabled apps with valid licenses
pull/14848/head^2
Tasso Evangelista 6 years ago committed by Douglas Gubert
parent 34f24f407b
commit ee2ed86482
  1. 1
      .stylelintignore
  2. 10
      app/api/server/api.js
  3. 206
      app/apps/assets/stylesheets/apps.css
  4. 4
      app/apps/client/admin/appLogs.js
  5. 190
      app/apps/client/admin/appManage.css
  6. 176
      app/apps/client/admin/appManage.html
  7. 846
      app/apps/client/admin/appManage.js
  8. 74
      app/apps/client/admin/apps.html
  9. 372
      app/apps/client/admin/apps.js
  10. 371
      app/apps/client/admin/helpers.js
  11. 68
      app/apps/client/admin/marketplace.css
  12. 93
      app/apps/client/admin/marketplace.html
  13. 687
      app/apps/client/admin/marketplace.js
  14. 4
      app/apps/client/communication/index.js
  15. 69
      app/apps/client/communication/websockets.js
  16. 38
      app/apps/client/i18n.js
  17. 2
      app/apps/client/index.js
  18. 271
      app/apps/client/orchestrator.js
  19. 55
      app/apps/client/routes.js
  20. 2
      app/apps/server/bridges/users.js
  21. 157
      app/apps/server/communication/rest.js
  22. 80
      app/apps/server/cron.js
  23. 9
      app/apps/server/orchestrator.js
  24. 4
      app/models/server/models/Users.js
  25. 51
      package-lock.json
  26. 2
      package.json
  27. 9
      packages/rocketchat-i18n/i18n/en.i18n.json

@ -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

@ -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,

@ -25,7 +25,7 @@
letter-spacing: 0;
text-transform: initial;
color: #54585e;
color: var(--color-dark-medium);
font-size: 22px;
font-weight: normal;
@ -60,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;
@ -156,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);
}
/*
@ -190,7 +102,7 @@
color: #9da2a9;
border-radius: 2px;
background: #f3f4f5;
background: var(--color-gray-lightest);
font-size: 12px;
font-weight: 500;
@ -240,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;
@ -329,6 +225,14 @@
}
}
& 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;
}
@ -362,9 +266,8 @@
text-transform: none;
text-overflow: ellipsis;
color: #9da1a8;
border-radius: 9999px;
background-color: #eef0f3;
color: var(--color-gray);
background-color: var(--color-gray-lightest);
font-size: 0.625rem;
font-weight: 500;
@ -382,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 {

@ -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) => {

@ -1,125 +1,170 @@
#rocket-chat .content .rc-apps-details {
#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;
}
&__app-name {
flex: 0 0 1.75rem;
&__description {
padding-bottom: 50px;
margin: 0;
border-bottom: 1.5px solid var(--color-gray-light);
}
letter-spacing: 0;
text-transform: none;
&__col {
display: inline-block;
color: rgb(84, 88, 94);
margin-right: 8px;
}
font-family: inherit;
font-size: 1.375rem;
font-weight: normal;
line-height: 1.75rem;
&__bundles {
display: flex;
padding-bottom: 20px;
border-bottom: 1.5px solid var(--color-gray-light);
}
&__app-info {
&__bundle {
display: flex;
flex: 0 0 1.25rem;
flex-wrap: nowrap;
> span::after {
display: inline-block;
width: 50%;
}
width: 1px;
height: 12px;
margin: 0 8px;
&__bundle_icons {
display: flex;
overflow: hidden;
content: '';
min-width: 99px;
max-width: 99px;
height: 99px;
background: rgb(203, 206, 209);
}
padding: 2px;
> span:last-child::after {
display: none;
border-radius: 2px;
content: none;
}
background-color: var(--color-gray-light);
flex-wrap: wrap;
}
&__app-author {
letter-spacing: -0.2px;
&__bundle_icon {
min-width: 40px;
max-width: 40px;
height: 40px;
color: rgb(158, 162, 168);
margin-top: 5px;
margin-left: 5px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
line-height: 20px;
border-radius: 2px;
background-color: #f7f7f7;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
}
&__app-version {
letter-spacing: -0.2px;
&__bundle_body {
padding: 5px 10px;
color: rgb(158, 162, 168);
color: var(--color-gray);
font-family: inherit;
font-size: 14px;
font-weight: normal;
line-height: 20px;
&_title {
color: var(--color-darkest);
font-size: 1.1em;
font-weight: 500;
}
}
&__app-status {
display: flex;
flex: 1;
&__alert {
margin: 0.25rem 0;
padding: 0.5rem 1rem;
margin-top: 8px;
align-items: center;
}
border-radius: 4px;
&__app-install-status {
display: flex;
font-size: 0.875rem;
line-height: 1.25rem;
}
height: 40px;
&__alert-error {
color: var(--color-red);
background-color: #ffe9ec;
}
letter-spacing: 0;
&__alert-warning {
color: #b68d00;
background-color: #fff6d6;
}
color: rgb(158, 162, 168);
&__name {
flex: 0 0 1.75rem;
font-family: inherit;
font-size: 14px;
font-weight: 500;
align-items: center;
flex-wrap: nowrap;
margin: 0;
& > .rc-icon {
color: var(--rc-color-button-primary);
}
}
text-transform: none;
&__app-price {
letter-spacing: -0.2px;
color: var(--color-dark-medium);
color: rgb(157, 161, 168);
font-family: inherit;
font-size: 1.375rem;
font-weight: normal;
line-height: 1.75rem;
}
&__author {
font-family: inherit;
font-size: 14px;
font-weight: normal;
font-weight: 500;
line-height: 20px;
}
&__side-info {
display: inline-flex;
align-items: center;
&::before {
display: inline-block;
width: 1px;
height: 12px;
margin: 0 16px;
margin: 0 8px;
content: '';
background: rgb(203, 206, 209);
background-color: currentColor;
}
&--twice::before {
margin: 0 16px;
}
}
&__app-button-wrapper {
&__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;
@ -133,15 +178,4 @@
animation: spin 1s linear infinite;
}
}
&__app-menu-trigger {
padding: 0;
&::before {
display: inline-block;
flex: 1;
content: '';
}
}
}

@ -1,19 +1,23 @@
<!-- This page allows for settings to be changed but not all of them are implemented yet -->
<template name="appManage">
{{#with app}}
<section class="page-container page-home page-static page-settings rc-apps-marketplace">
<section class="page-container page-home page-static page-settings rc-apps-section">
{{# header sectionName='App_Details' fixedHeight=true hideHelp=true fullpage=true}}
<div class="rc-header__section-button">
{{#unless disabled}}
<button class="rc-button rc-button--cancel js-cancel-editing">{{_ "Cancel" }}</button>
{{/unless}}
<button class="rc-button rc-button--primary js-save {{#if saving}} loading{{/if}}" disabled='{{disabled}}'>{{_ "Save_changes" }}</button>
</div>
{{#if installed}}
<div class="rc-header__section-button">
{{#unless isSettingsPristine}}
<button class="rc-button rc-button--cancel js-cancel-editing-settings">{{_ "Cancel" }}</button>
{{/unless}}
<button class="rc-button rc-button--primary js-save-settings {{#if isSaving}}loading{{/if}}" disabled='{{isSettingsPristine}}'>{{_ "Save_changes" }}</button>
</div>
{{/if}}
<div class="rc-header__block rc-header__block-action">
<button class="rc-button rc-button--nude js-cancel">{{> icon icon="cross"}}</button>
<button class="rc-button rc-button--nude js-close">
{{> icon icon="cross"}}
</button>
</div>
{{/header}}
<div class="content">
{{#requiresPermission 'manage-apps'}}
{{#if error}}
@ -21,7 +25,9 @@
<i class="icon-attention"></i>
<p>{{error}}</p>
</div>
{{else if isReady}}
{{else if isLoading}}
{{> loading}}
{{else}}
<div class="rc-apps-details">
<div class="rc-apps-container rc-apps-container__header">
{{#if iconFileData}}
@ -30,61 +36,55 @@
<div class="rc-apps-details__photo" style="background-image:url({{iconFileContent}})"></div>
{{/if}}
<div class="rc-apps-details__content">
<h2 class="rc-apps-details__app-name">{{name}}</h2>
<div class="rc-apps-details__app-info">
<h2 class="rc-apps-details__name">{{name}}</h2>
<div class="rc-apps-details__row">
{{#if author.name}}
<span class="rc-apps-details__app-author">by {{author.name}}</span>
<span class="rc-apps-details__author">by {{author.name}}</span>
{{/if}}
<span class="rc-apps-details__app-version">Version {{version}}</span>
<span class="rc-apps-details__side-info">Version {{version}}</span>
</div>
<div class="rc-apps-details__app-status">
{{#if isInstalled}}
<span class="rc-apps-details__app-button-wrapper">
{{#if canUpdate}}
<button class="rc-button rc-button--primary js-install">
<div class="rc-apps-details__row rc-apps-details__row--centered">
{{#let buttonProps=(appButtonProps .) statusSpanProps=(appStatusSpanProps .)}}
{{#if buttonProps}}
<button
class="rc-button rc-button--primary {{#if working}}rc-apps-section__button--working{{/if}} js-{{buttonProps.action}}"
disabled={{working}}
data-id={{id}}
>
{{#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"}}
{{_ "Update_to_version" version=newVersion }}
</button>
{{else if isFromMarketplace}}
<span class="rc-apps-details__app-install-status">
{{> icon icon="checkmark-circled" block="rc-icon--default-size"}}
{{_ "Up to date"}}
</span>
{{else}}
<span class="rc-apps-details__app-install-status">
{{> icon icon="checkmark-circled" block="rc-icon--default-size"}}
{{_ "Installed"}}
</span>
{{/if}}
</span>
<button class="rc-button rc-button--nude rc-apps-details__app-menu-trigger js-menu" data-app="{{appId}}">
{{> icon icon="menu" block="rc-icon--default-size"}}
</button>
{{else}}
{{#if canTrial}}
<button class="rc-button rc-button--primary js-purchase" data-app="{{appId}}">
{{> icon icon="circled-arrow-down" block="rc-icon--default-size"}}
{{_ "Start a trial"}}
</button>
{{else if canBuy}}
<button class="rc-button rc-button--primary js-purchase" data-app="{{appId}}">
{{> icon icon="circled-arrow-down" block="rc-icon--default-size"}}
{{_ "Buy"}}
</button>
{{else}}
<button class="rc-button rc-button--primary js-install" data-app="{{appId}}">
{{> icon icon="circled-arrow-down" block="rc-icon--default-size"}}
{{_ "Install"}}
{{else}}
{{> icon icon="circled-arrow-down" block="rc-icon--default-size"}}
{{/if}}
{{_ buttonProps.label}}
</button>
{{/if}}
{{#if priceDisplay}}
<span class="rc-apps-details__app-price">
{{priceDisplay}}
{{#if statusSpanProps}}
<span class="rc-apps-section__status {{#if statusSpanProps.type}}rc-apps-section__status--{{statusSpanProps.type}}{{/if}}">
{{> icon icon=statusSpanProps.icon block="rc-icon--default-size"}}
{{_ statusSpanProps.label}}
</span>
{{/if}}
{{/let}}
<span class="rc-apps-details__side-info-wrapper">
{{#unless installed}}
{{#if priceDisplay}}
<span class="rc-apps-details__side-info rc-apps-details__side-info--twice">
{{priceDisplay}}
</span>
{{/if}}
{{/unless}}
</span>
{{#if installed}}
<button class="rc-apps-section__app-menu-trigger js-menu" data-app="{{appId}}">
{{> icon icon="menu" block="rc-icon--default-size"}}
</button>
{{/if}}
</div>
</div>
@ -92,6 +92,18 @@
</div>
<div class="rc-apps-container">
<div class="rc-apps-details__content">
{{#each warnings}}
<div class="rc-apps-details__alert rc-apps-details__alert-warning">
{{.}}
</div>
{{/each}}
{{#each errors}}
<div class="rc-apps-details__alert rc-apps-details__alert-error">
{{.}}
</div>
{{/each}}
{{#if categories}}
<div class="rc-apps-details__row rc-apps-details__block">
<h2> {{_ "Categories"}} </h2>
@ -156,7 +168,9 @@
</div>
<div class="rc-apps-details__bundle_body">
<div class="rc-apps-details__bundle_body_title">{{ bundleName }}</div>
{{bundleAppNames apps}}
{{#if apps}}
{{bundleAppNames apps}}
{{/if}}
</div>
</div>
{{/each}}
@ -205,14 +219,14 @@
</div>
</label>
{{#if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{#if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -227,13 +241,13 @@
{{_ i18nLabel}}
</span>
</label>
<span class="rc-switch__description">{{{parseDescription i18nDescription}}}</span>
<span class="rc-switch__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</span>
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -250,14 +264,14 @@
</div>
</label>
{{# if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -274,14 +288,14 @@
</div>
</label>
{{# if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -296,14 +310,14 @@
</div>
</label>
{{# if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -320,14 +334,14 @@
</div>
</label>
{{# if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -353,14 +367,14 @@
</div>
</label>
{{# if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -379,14 +393,14 @@
</div>
</label>
{{# if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -404,14 +418,14 @@
</div>
</label>
{{# if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -422,22 +436,22 @@
<div class="rc-input__title">{{_ i18nLabel}}</div>
<div class="rc-select">
<select class="rc-select__element" name="{{id}}">
{{#each languages}}
<option class="rc-select__option" value="{{key}}" selected="{{selectedOption key}}">{{_ name}}</option>
{{#each language in languages}}
<option class="rc-select__option" value="{{language.key}}" selected="{{selectedOption language.key}}">{{_ language.name}}</option>
{{/each}}
</select>
{{> icon block="rc-select__arrow" icon="arrow-down" }}
</div>
</label>
{{# if i18nDescription}}
<div class="rc-input__description">{{{parseDescription i18nDescription}}}</div>
<div class="rc-input__description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{# if i18nAlert}}
<div class="rc-input__error">
<div class="rc-input__error-icon">
{{> icon block="rc-input__error-icon" icon="warning" classes="rc-input__error-icon-svg"}}
</div>
<div class="rc-input__error-message">{{{parseDescription i18nAlert}}}</div>
<div class="rc-input__error-message">{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
</div>
{{/if}}
</div>
@ -485,10 +499,10 @@
{{/if}}
{{#if i18nDescription}}
<div class="settings-description">{{{parseDescription i18nDescription}}}</div>
<div class="settings-description">{{{RocketChatMarkdown (_ i18nDescription)}}}</div>
{{/if}}
{{#if i18nAlert}}
<div class="settings-alert pending-color pending-background pending-border"><i class="icon-attention secondary-font-color"></i>{{{parseDescription i18nAlert}}}</div>
<div class="settings-alert pending-color pending-background pending-border"><i class="icon-attention secondary-font-color"></i>{{{RocketChatMarkdown (_ i18nAlert)}}}</div>
{{/if}}
</div>
</div>
@ -500,8 +514,6 @@
{{/if}}
</div>
</div>
{{else}}
{{> loading}}
{{/if}}
{{/requiresPermission}}
</div>

File diff suppressed because it is too large Load Diff

@ -2,16 +2,17 @@
<section class="rc-apps-section">
{{#header sectionName="Apps" hideHelp=true fixedHeight=true fullpage=true}}
<div class="rc-header__section-button">
<button class="rc-button rc-button--small rc-button--primary" data-button="install_app">
<button class="rc-button rc-button--small rc-button--primary js-marketplace">
{{> icon icon="cloud-plus" block="rc-icon--default-size"}} {{_ "Marketplace_view_marketplace"}}
</button>
{{#if appsDevelopmentMode}}
<button class="rc-button rc-button--small rc-button--primary rc-button--outline" data-button="upload_app">
{{#if isDevelopmentModeEnabled}}
<button class="rc-button rc-button--small rc-button--primary rc-button--outline js-upload">
{{> icon icon="upload" block="rc-icon--default-size"}} {{_ "Upload_app"}}
</button>
{{/if}}
</div>
{{/header}}
<div class="rc-table-content">
<form class="rc-form-filters js-search-form" role="form">
<div class="rc-input">
@ -33,33 +34,36 @@
</form>
{{#requiresPermission 'manage-apps'}}
{{#table fixed='true' onScroll=onTableScroll onResize=onTableResize onSort=onTableSort}}
{{#table fixed='true' onScroll=handleTableScroll onResize=handleTableResize onSort=handleTableSort}}
<thead>
<tr>
<th class="js-sort rc-table-td--medium {{#if searchSortBy 'name'}}is-sorting{{/if}}" data-sort="name">
<th class="rc-table-td--medium {{#if isSortingBy 'name'}}is-sorting{{/if}} js-sort" data-sort="name">
<div class="table-fake-th">{{_ "Name"}} {{> icon icon=(sortIcon 'name')}}</div>
</th>
<th class="rc-table-td">
<div class="table-fake-th">{{_ "Details"}} </div>
<div class="table-fake-th">{{_ "Details"}}</div>
</th>
<th class="rc-apps-marketplace__status-column">
<div class="table-fake-th">{{_ "Status"}}</div>
</th>
</tr>
</thead>
<tbody>
{{#each apps}}
<tr class="rc-table-tr manage" data-name="{{latest.name}}">
{{#each app in apps}}
<tr class="rc-table-tr js-manage" data-id="{{app.id}}">
<td>
<div class="rc-table-wrapper">
{{#if latest.iconFileData}}
<div class="rc-table-avatar" style="background-image:url(data:image/png;base64,{{latest.iconFileData}})"></div>
{{#if app.iconFileData}}
<div class="rc-table-avatar" style="background-image:url(data:image/png;base64,{{app.iconFileData}})"></div>
{{else}}
<div class="rc-table-avatar" style="background-image:url({{latest.iconFileContent}})"></div>
<div class="rc-table-avatar" style="background-image:url({{app.iconFileContent}})"></div>
{{/if}}
<div class="rc-table-info">
<span class="rc-table-title">
{{latest.name}}
{{app.name}}
</span>
{{#if latest.author.name}}
<span class="rc-table-subtitle">by {{latest.author.name}}</span>
{{#if app.author.name}}
<span class="rc-table-subtitle">by {{app.author.name}}</span>
{{/if}}
</div>
</div>
@ -68,30 +72,52 @@
<div class="rc-table-wrapper">
<div class="rc-table-info">
<span class="rc-table-title">
{{#if latest.summary}}
{{latest.summary}}
{{#if app.summary}}
{{app.summary}}
{{else}}
{{latest.description}}
{{app.description}}
{{/if}}
</span>
{{#if latest.summary}}
<span class="rc-table-subtitle">
{{latest.description}}
</span>
{{/if}}
<span class="rc-table-subtitle rc-apps-categories">
{{#each category in latest.categories}}
{{#each category in app.categories}}
<span class="rc-apps-category">{{category}}</span>
{{/each}}
</span>
</div>
</div>
</td>
<td>
<div class="rc-table-wrapper">
{{#let buttonProps=(appButtonProps app) statusSpanProps=(appStatusSpanProps app)}}
{{#if buttonProps}}
<button
class="rc-button rc-button--primary {{#unless $eq buttonProps.action 'update'}}rc-apps-section__table-button--hideable{{/unless}} {{#if app.working}}rc-apps-section__table-button--working{{/if}} js-{{buttonProps.action}}"
disabled={{app.working}}
data-id={{app.id}}
>
{{#if app.working}}
{{> icon icon="loading" block="rc-icon--default-size rc-apps-section__spinning-icon"}}
{{/if}}
{{_ buttonProps.label}}
</button>
{{else if statusSpanProps}}
<span class="rc-apps-section__status {{#if statusSpanProps.type}}rc-apps-section__status--{{statusSpanProps.type}}{{/if}}">
{{> icon icon=statusSpanProps.icon block="rc-icon--default-size"}}
{{_ statusSpanProps.label}}
</span>
{{/if}}
{{/let}}
<button class="rc-apps-section__app-menu-trigger js-menu" data-id="{{app.id}}">
{{> icon icon="menu" block="rc-icon--default-size"}}
</button>
</div>
</td>
</tr>
{{/each}}
{{#if isLoading}}
<tr>
<td colspan="3" style="position: relative;">{{> loading}}</td>
<td colspan="3" style="position: relative">{{> loading}}</td>
</tr>
{{/if}}
</tbody>

@ -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);
},
});

@ -0,0 +1,371 @@
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'),
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;
}
}
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 = (pricingPlan) => {
const perUser = pricingPlan.isPerSeat && pricingPlan.tiers && pricingPlan.tiers.length;
const pricingPlanTranslationString = [
'Apps_Marketplace_pricingPlan',
pricingPlan.strategy,
perUser && 'perUser',
].filter(Boolean).join('_');
return t(pricingPlanTranslationString, {
price: formatPrice(pricingPlan.price),
});
};

@ -1,68 +0,0 @@
.rc-apps-marketplace {
&__app-status,
&__app-button,
&__app-menu-trigger {
display: flex;
flex: 1;
padding: 0;
font-size: 0.875rem;
line-height: 1.25rem;
align-items: center;
appearance: none;
}
&__app-menu-trigger,
&__app-button {
position: relative;
&: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;
}
}
&__app-button {
color: var(--rc-color-button-primary);
&.loading {
opacity: 0.6;
& > .rc-icon {
animation: spin 1s linear infinite;
}
}
}
&__app-menu-trigger {
visibility: hidden;
flex: 0 0 auto;
}
.rc-table-content .rc-table {
.rc-table-tr:hover .rc-apps-marketplace__app-menu-trigger {
visibility: visible;
}
.rc-table-td--small:last-child {
width: 150px;
}
}
}

@ -1,12 +1,13 @@
<template name="marketplace">
<section class="rc-apps-section rc-apps-marketplace">
{{#header sectionName="Marketplace" hideHelp=true fixedHeight=true fullpage=true}}
{{#unless cloudLoggedIn}}
<button class="rc-button rc-button--small rc-button--primary rc-button--outline" data-button="login">
{{#unless isLoggedInCloud}}
<button class="rc-button rc-button--small rc-button--primary rc-button--outline js-cloud-login" data-button="login">
{{> icon icon="cloud-plus" block="rc-icon--default-size"}} {{_ "Login"}}
</button>
{{/unless}}
{{/header}}
<div class="rc-table-content">
<form class="rc-form-filters js-search-form" role="form">
<div class="rc-input">
@ -28,10 +29,10 @@
</form>
{{#requiresPermission 'manage-apps'}}
{{#table fixed='true' onScroll=onTableScroll onResize=onTableResize onSort=onTableSort}}
{{#table fixed='true' onScroll=handleTableScroll onResize=handleTableResize onSort=handleTableSort}}
<thead>
<tr>
<th class="js-sort rc-table-td--medium {{#if searchSortBy 'name'}}is-sorting{{/if}}" data-sort="name">
<th class="js-sort rc-table-td--medium {{#if isSortingBy 'name'}}is-sorting{{/if}}" data-sort="name">
<div class="table-fake-th">{{_ "Name"}} {{> icon icon=(sortIcon 'name')}}</div>
</th>
<th class="rc-table-td">
@ -40,27 +41,27 @@
<th class="rc-table-td--medium">
<div class="table-fake-th">{{_ "Price"}}</div>
</th>
<th class="rc-table-td--small">
<th class="rc-apps-section__status-column">
<div class="table-fake-th">{{_ "Status"}}</div>
</th>
</tr>
</thead>
<tbody>
{{#each apps}}
<tr class="rc-table-tr js-open" data-name="{{latest.name}}">
{{#each app in apps}}
<tr class="rc-table-tr js-open" data-id="{{app.id}}">
<td>
<div class="rc-table-wrapper">
{{#if latest.iconFileData}}
<div class="rc-table-avatar" style="background-image:url(data:image/png;base64,{{latest.iconFileData}})"></div>
{{#if app.iconFileData}}
<div class="rc-table-avatar" style="background-image:url(data:image/png;base64,{{app.iconFileData}})"></div>
{{else}}
<div class="rc-table-avatar" style="background-image:url({{latest.iconFileContent}})"></div>
<div class="rc-table-avatar" style="background-image:url({{app.iconFileContent}})"></div>
{{/if}}
<div class="rc-table-info">
<span class="rc-table-title">
{{latest.name}}
{{app.name}}
</span>
{{#if latest.author.name}}
<span class="rc-table-subtitle">by {{latest.author.name}}</span>
{{#if app.author.name}}
<span class="rc-table-subtitle">by {{app.author.name}}</span>
{{/if}}
</div>
</div>
@ -69,19 +70,19 @@
<div class="rc-table-wrapper">
<div class="rc-table-info">
<span class="rc-table-title">
{{#if latest.summary}}
{{latest.summary}}
{{#if app.summary}}
{{app.summary}}
{{else}}
{{latest.description}}
{{app.description}}
{{/if}}
</span>
{{#if latest.summary}}
{{#if app.summary}}
<span class="rc-table-subtitle">
{{latest.description}}
{{app.description}}
</span>
{{/if}}
<span class="rc-table-subtitle rc-apps-categories">
{{#each category in latest.categories}}
{{#each category in app.categories}}
<span class="rc-apps-category">{{category}}</span>
{{/each}}
</span>
@ -92,54 +93,40 @@
<div class="rc-table-wrapper">
<div class="rc-table-info">
<div class="rc-table-title">
{{purchaseTypeDisplay .}}
{{purchaseTypeDisplay app}}
</div>
<div class="rc-table-subtitle">
{{priceDisplay .}}
{{priceDisplay app}}
</div>
</div>
</div>
</td>
<td>
<div class="rc-table-wrapper">
{{#if isInstalled .}}
{{#if canUpdate .}}
<button class="rc-apps-marketplace__app-button js-install">
{{> icon icon="reload" block="rc-icon--default-size"}}
{{_ "Update"}}
{{#let buttonProps=(appButtonProps app) statusSpanProps=(appStatusSpanProps app)}}
{{#if buttonProps}}
<button
class="rc-button rc-button--primary {{#unless $eq buttonProps.action 'update'}}rc-apps-section__table-button--hideable{{/unless}} {{#if app.working}}rc-apps-section__table-button--working{{/if}} js-{{buttonProps.action}}"
disabled={{app.working}}
data-id={{app.id}}
>
{{#if app.working}}
{{> icon icon="loading" block="rc-icon--default-size rc-apps-section__spinning-icon"}}
{{/if}}
{{_ buttonProps.label}}
</button>
{{else if isOnTrialPeriod .}}
<span class="rc-apps-marketplace__app-status">
{{> icon icon="checkmark-circled" block="rc-icon--default-size"}}
{{_ "Trial period"}}
</span>
{{else}}
<span class="rc-apps-marketplace__app-status">
{{> icon icon="checkmark-circled" block="rc-icon--default-size"}}
{{_ "Installed"}}
{{else if statusSpanProps}}
<span class="rc-apps-section__status {{#if statusSpanProps.type}}rc-apps-section__status--{{statusSpanProps.type}}{{/if}}">
{{> icon icon=statusSpanProps.icon block="rc-icon--default-size"}}
{{_ statusSpanProps.label}}
</span>
{{/if}}
{{/let}}
<button class="rc-apps-marketplace__app-menu-trigger js-menu" data-app="{{appId}}">
{{#if app.installed}}
<button class="rc-apps-section__app-menu-trigger js-menu" data-id="{{app.id}}">
{{> icon icon="menu" block="rc-icon--default-size"}}
</button>
{{else}}
{{#if canTrial .}}
<button class="rc-apps-marketplace__app-button js-purchase" data-app="{{appId}}">
{{> icon icon="circled-arrow-down" block="rc-icon--default-size"}}
{{_ "Start a trial"}}
</button>
{{else if canBuy .}}
<button class="rc-apps-marketplace__app-button js-purchase" data-app="{{appId}}">
{{> icon icon="circled-arrow-down" block="rc-icon--default-size"}}
{{_ "Buy"}}
</button>
{{else}}
<button class="rc-apps-marketplace__app-button js-install" data-app="{{appId}}">
{{> icon icon="circled-arrow-down" block="rc-icon--default-size"}}
{{_ "Install"}}
</button>
{{/if}}
{{/if}}
</div>
</td>

@ -1,540 +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 semver from 'semver';
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, popover } from '../../../ui-utils/client';
import {
appButtonProps,
appStatusSpanProps,
checkCloudLogin,
formatPrice,
formatPricingPlan,
handleAPIError,
promptSubscription,
triggerAppPopoverMenu,
warnStatusChange,
} from './helpers';
import './marketplace.html';
import './marketplace.css';
const ENABLED_STATUS = ['auto_enabled', 'manually_enabled'];
const enabled = ({ status }) => ENABLED_STATUS.includes(status);
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 getCloudLoggedIn = async (instance) => {
Meteor.call('cloud:checkUserLoggedIn', (error, result) => {
if (error) {
console.warn(error);
return;
}
instance.cloudLoggedIn.set(result);
});
};
const handleAPIError = (e, instance) => {
console.error(e);
const errMsg = (e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message;
toastr.error(errMsg);
if (errMsg === 'Unauthorized') {
getCloudLoggedIn(instance);
}
};
const getApps = async (instance) => {
instance.isLoading.set(true);
try {
const data = await APIClient.get('apps?marketplace=true');
instance.apps.set(data);
} catch (e) {
handleAPIError(e, instance);
}
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) {
handleAPIError(e, instance);
}
};
const formatPrice = (price) => `\$${ Number.parseFloat(price).toFixed(2) }`;
const formatPricingPlan = (pricingPlan) => {
const perUser = pricingPlan.isPerSeat && pricingPlan.tiers && pricingPlan.tiers.length;
const pricingPlanTranslationString = [
'Apps_Marketplace_pricingPlan',
pricingPlan.strategy,
perUser && 'perUser',
].filter(Boolean).join('_');
return t(pricingPlanTranslationString, {
price: formatPrice(pricingPlan.price),
});
};
const isLoggedInCloud = (instance) => {
if (instance.cloudLoggedIn.get()) {
return true;
}
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('/admin/cloud');
(async () => {
try {
this.state.set('isLoggedInCloud', await call('cloud:checkUserLoggedIn'));
} catch (error) {
handleAPIError(error);
}
});
return false;
};
try {
const appsFromMarketplace = await Apps.getAppsFromMarketplace();
const installedApps = await Apps.getApps();
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,
};
});
const triggerButtonLoadingState = (button) => {
const icon = button.querySelector('.rc-icon use');
const iconHref = icon.getAttribute('href');
this.state.set('apps', apps);
} catch (error) {
handleAPIError(error);
} finally {
this.state.set('isLoading', false);
}
})();
button.classList.add('loading');
button.disabled = true;
icon.setAttribute('href', '#icon-loading');
this.startAppWorking = (appId) => {
const apps = this.state.get('apps');
const app = apps.find(({ id }) => id === appId);
app.working = true;
this.state.set('apps', apps);
};
return () => {
button.classList.remove('loading');
button.disabled = false;
icon.setAttribute('href', iconHref);
this.stopAppWorking = (appId) => {
const apps = this.state.get('apps');
const app = apps.find(({ id }) => id === appId);
delete app.working;
this.state.set('apps', apps);
};
};
const promptSubscription = async ({ latest, purchaseType = 'buy' }, instance) => {
let data = null;
try {
data = await APIClient.get(`apps?buildExternalUrl=true&appId=${ latest.id }&purchaseType=${ purchaseType }`);
} catch (e) {
handleAPIError(e, instance);
return;
}
modal.open({
allowOutsideClick: false,
data,
template: 'iframeModal',
}, async () => {
try {
await APIClient.post('apps/', {
appId: latest.id,
marketplace: true,
version: latest.version,
});
await Promise.all([
getInstalledApps(instance),
getApps(instance),
]);
} catch (e) {
handleAPIError(e, instance);
}
});
};
const setAppStatus = async (installedApp, status, instance) => {
try {
const result = await APIClient.post(`apps/${ installedApp.latest.id }/status`, { status });
installedApp.latest.status = result.status;
instance.installedApps.set(instance.installedApps.get());
} catch (e) {
handleAPIError(e, instance);
}
};
const activateApp = (installedApp, instance) => {
if (!isLoggedInCloud(instance)) {
return;
}
setAppStatus(installedApp, 'manually_enabled', instance);
};
const promptAppDeactivation = (installedApp, instance) => {
if (!isLoggedInCloud(instance)) {
return;
}
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,
}, (confirmed) => {
if (!confirmed) {
return;
}
setAppStatus(installedApp, 'manually_disabled', instance);
});
};
const uninstallApp = async (installedApp, instance) => {
try {
await APIClient.delete(`apps/${ installedApp.latest.id }`);
const installedApps = instance.installedApps.get().filter((app) => app.latest.id !== installedApp.latest.id);
instance.installedApps.set(installedApps);
} catch (e) {
handleAPIError(e, instance);
}
};
const promptAppUninstall = (installedApp, instance) => {
if (!isLoggedInCloud(instance)) {
return;
}
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,
}, (confirmed) => {
if (!confirmed) {
return;
}
uninstallApp(installedApp, instance);
});
};
Template.marketplace.onCreated(function() {
this.ready = new ReactiveVar(false);
this.apps = new ReactiveVar([]);
this.installedApps = 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(this);
getApps(this);
getCloudLoggedIn(this);
this.onAppAdded = async (appId) => {
const installedApps = this.installedApps.get().filter((installedApp) => installedApp.appId !== appId);
this.handleAppAddedOrUpdated = async (appId) => {
try {
const { app } = await APIClient.get(`apps/${ appId }`);
installedApps.push({ latest: app });
this.installedApps.set(installedApps);
} catch (e) {
handleAPIError(e, this);
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);
}
};
this.onAppRemoved = (appId) => {
const apps = this.apps.get().filter(({ id }) => id !== appId);
this.apps.set(apps);
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 app;
});
this.state.set('apps', 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, this.onAppAdded);
Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, this.onAppRemoved);
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() {
Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, this.onAppAdded);
Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, this.onAppRemoved);
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);
});
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';
const apps = instance.apps.get().filter((app) => app.latest.name.toLowerCase().includes(searchText));
return sortByColumn(apps, sortColumn, inverted);
},
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();
Template.marketplace.onRendered(() => {
Tracker.afterFlush(() => {
SideNav.setFlex('adminFlex');
SideNav.openFlex();
});
});
return key === searchSortBy.get() && sortDirection.get() !== 'asc' ? 'sort-up' : 'sort-down';
},
searchSortBy(key) {
return Template.instance().searchSortBy.get() === key;
Template.marketplace.helpers({
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,
});
};
},
purchaseTypeDisplay(app) {
if (app.purchaseType === 'subscription') {
isSortingBy(column) {
return Template.instance().state.get('sortedColumn') === column;
},
sortIcon(column) {
const { state } = Template.instance();
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;
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');
}
if (app.price > 0) {
if (price > 0) {
return t('Paid');
}
return t('Free');
},
priceDisplay(app) {
if (app.purchaseType === 'subscription') {
if (!app.pricingPlans || !Array.isArray(app.pricingPlans) || app.pricingPlans.length === 0) {
priceDisplay({ purchaseType, pricingPlans, price }) {
if (purchaseType === 'subscription') {
if (!pricingPlans || !Array.isArray(pricingPlans) || pricingPlans.length === 0) {
return '-';
}
return formatPricingPlan(app.pricingPlans[0]);
return formatPricingPlan(pricingPlans[0]);
}
if (app.price > 0) {
return formatPrice(app.price);
if (price > 0) {
return formatPrice(price);
}
return '-';
},
isInstalled(app) {
const { installedApps } = Template.instance();
const installedApp = installedApps.get().find(({ latest: { id } }) => id === app.latest.id);
return !!installedApp;
},
isOnTrialPeriod(app) {
return app.subscriptionInfo.status === 'trialing';
},
canUpdate(app) {
const { installedApps } = Template.instance();
const installedApp = installedApps.get().find(({ latest: { id } }) => id === app.latest.id);
return !!installedApp && semver.lt(installedApp.latest.version, app.latest.version);
},
canTrial(app) {
return app.purchaseType === 'subscription' && !app.subscriptionInfo.status;
},
canBuy(app) {
return app.price > 0;
},
appButtonProps,
appStatusSpanProps,
});
Template.marketplace.events({
'click [data-button="install"]'() {
FlowRouter.go('/admin/app/install');
'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;
},
'keyup .js-search'(event, instance) {
instance.state.set('searchText', event.currentTarget.value);
},
'click .js-open'(e) {
e.stopPropagation();
const { latest: { id, version } } = this;
FlowRouter.go(`/admin/apps/${ id }?version=${ version }`);
'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'(e, instance) {
e.stopPropagation();
async 'click .js-install, click .js-update'(event, instance) {
event.preventDefault();
event.stopPropagation();
if (!isLoggedInCloud(instance)) {
const isLoggedInCloud = await checkCloudLogin();
instance.state.set('isLoggedInCloud', isLoggedInCloud);
if (!isLoggedInCloud) {
return;
}
const { currentTarget: button } = e;
const stopLoading = triggerButtonLoadingState(button);
const { currentTarget: button } = event;
const app = instance.state.get('apps').find(({ id }) => id === button.dataset.id);
const { latest } = this;
instance.startAppWorking(app.id);
try {
await APIClient.post('apps/', {
appId: latest.id,
marketplace: true,
version: latest.version,
});
await Promise.all([
getInstalledApps(instance),
getApps(instance),
]);
} catch (e) {
handleAPIError(e, instance);
const { status } = await Apps.installApp(app.id, app.marketplaceVersion);
warnStatusChange(app.name, status);
} catch (error) {
handleAPIError(error);
} finally {
stopLoading();
instance.stopAppWorking(app.id);
}
},
async 'click .js-purchase'(e, instance) {
e.stopPropagation();
async 'click .js-purchase'(event, instance) {
event.preventDefault();
event.stopPropagation();
if (!isLoggedInCloud(instance)) {
const isLoggedInCloud = await checkCloudLogin();
instance.state.set('isLoggedInCloud', isLoggedInCloud);
if (!isLoggedInCloud) {
return;
}
const { latest, purchaseType = 'buy' } = this;
const { currentTarget: button } = e;
const stopLoading = triggerButtonLoadingState(button);
const { currentTarget: button } = event;
const app = instance.state.get('apps').find(({ id }) => id === button.dataset.id);
let data = null;
try {
data = await APIClient.get(`apps?buildExternalUrl=true&appId=${ latest.id }&purchaseType=${ purchaseType }`);
} catch (e) {
handleAPIError(e, instance);
stopLoading();
return;
}
instance.startAppWorking(app.id);
modal.open({
allowOutsideClick: false,
data,
template: 'iframeModal',
}, async () => {
await promptSubscription(app, async () => {
try {
await APIClient.post('apps/', {
appId: latest.id,
marketplace: true,
version: latest.version,
});
await Promise.all([
getInstalledApps(instance),
getApps(instance),
]);
} catch (e) {
handleAPIError(e, instance);
const { status } = await Apps.installApp(app.id, app.marketplaceVersion);
warnStatusChange(app.name, status);
} catch (error) {
handleAPIError(error);
} finally {
stopLoading();
instance.stopAppWorking(app.id);
}
}, stopLoading);
}, instance.stopAppWorking.bind(instance, app.id));
},
'click .js-menu'(e, instance) {
e.stopPropagation();
const { currentTarget } = e;
const installedApp = instance.installedApps.get().find(({ latest: { id } }) => id === this.latest.id);
const isActive = installedApp && ['auto_enabled', 'manually_enabled'].includes(installedApp.latest.status);
popover.open({
currentTarget,
instance,
columns: [{
groups: [
...this.purchaseType === 'subscription' ? [{
items: [
{
icon: 'card',
name: t('Subscription'),
action: () => promptSubscription(this, instance),
},
],
}] : [],
{
items: [
isActive
? {
icon: 'ban',
name: t('Deactivate'),
modifier: 'alert',
action: () => promptAppDeactivation(installedApp, instance),
}
: {
icon: 'check',
name: t('Activate'),
action: () => activateApp(installedApp, instance),
},
{
icon: 'trash',
name: t('Uninstall'),
modifier: 'alert',
action: () => promptAppUninstall(installedApp, instance),
},
],
},
],
}],
});
},
'keyup .js-search'(e, t) {
t.searchText.set(e.currentTarget.value);
},
'submit .js-search-form'(e) {
e.preventDefault();
e.stopPropagation();
},
});
'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);
},
});

@ -1,3 +1 @@
import { AppWebsocketReceiver, AppEvents } from './websockets';
export { AppWebsocketReceiver, AppEvents };
export { AppWebsocketReceiver, AppEvents } from './websockets';

@ -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;
});
}
}

@ -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);
};

@ -1,7 +1,6 @@
import './admin/modalTemplates/iframeModal.html';
import './admin/modalTemplates/iframeModal';
import './admin/marketplace';
import './admin/apps.html';
import './admin/apps';
import './admin/appInstall.html';
import './admin/appInstall';
@ -10,5 +9,6 @@ import './admin/appLogs';
import './admin/appManage';
import './admin/appWhatIsIt.html';
import './admin/appWhatIsIt';
import './routes';
export { Apps } from './orchestrator';

@ -1,189 +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) {
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);
});
});
});

@ -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'),
});

@ -18,6 +18,6 @@ export class AppUserBridge {
}
async getActiveUserCount() {
return Users.findActive().count() - Users.findActiveRemote().count();
return Users.getActiveLocalUserCount();
}
}

@ -6,6 +6,8 @@ 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,
@ -69,11 +71,18 @@ export class AppsRestApi {
headers.Authorization = `Bearer ${ token }`;
}
const result = HTTP.get(`${ baseUrl }/v1/apps?version=${ Info.marketplaceApiVersion }`, {
headers,
});
let result;
try {
result = HTTP.get(`${ baseUrl }/v1/apps?version=${ Info.marketplaceApiVersion }`, {
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();
}
@ -87,11 +96,18 @@ export class AppsRestApi {
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();
}
@ -110,10 +126,14 @@ export class AppsRestApi {
return API.v1.failure({ error: 'Unauthorized' });
}
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 : 'subscribe'
}?workspaceId=${ workspaceId }&token=${ token }`,
this.queryParams.purchaseType === 'buy' ? this.queryParams.purchaseType : subscribeRoute
}?workspaceId=${ workspaceId }&token=${ token }&seats=${ seats }`,
});
}
@ -136,7 +156,13 @@ export class AppsRestApi {
return API.v1.failure({ error: 'Installation from url is disabled.' });
}
const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: null } });
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".' });
@ -221,7 +247,7 @@ export class AppsRestApi {
return API.v1.success({
app: info,
implemented: aff.getImplementedInferfaces(),
warnings: aff.getLicenseValidationResult().getWarnings(),
licenseValidation: aff.getLicenseValidationResult(),
});
},
});
@ -247,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();
}
@ -270,11 +303,18 @@ export class AppsRestApi {
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) {
orchestrator.getRocketChatLogger().error('Error getting the App information 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 App information from the Marketplace:', result.data);
return API.v1.failure();
}
@ -290,11 +330,18 @@ export class AppsRestApi {
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) {
orchestrator.getRocketChatLogger().error('Error getting the App update info from the Marketplace:', e.response.data);
return API.v1.internalError();
}
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();
}
@ -305,10 +352,16 @@ 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() {
@ -319,13 +372,13 @@ 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();
@ -335,12 +388,19 @@ export class AppsRestApi {
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();
}
@ -348,7 +408,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.' });
@ -392,6 +452,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);

@ -1,17 +1,85 @@
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 } from '../../models/server';
import { Settings, Users, Roles } from '../../models/server';
export const appsUpdateMarketplaceInfo = Meteor.bindEnvironment(() => {
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 fullUrl = `${ baseUrl }/v1/workspaces/${ workspaceIdSetting.value }/apps`;
const currentSeats = Users.getActiveLocalUserCount();
const fullUrl = `${ baseUrl }/v1/workspaces/${ workspaceIdSetting.value }/apps?seats=${ currentSeats }`;
const options = {
headers: {
Authorization: `Bearer ${ token }`,
@ -30,7 +98,11 @@ export const appsUpdateMarketplaceInfo = Meteor.bindEnvironment(() => {
Apps.debugLog(err);
}
Promise.await(Apps.updateAppsMarketplaceInfo(data));
Promise.await(
Apps.updateAppsMarketplaceInfo(data)
.then(notifyAdminsAboutInvalidApps)
.then(notifyAdminsAboutRenewedApps)
);
});
SyncedCron.add({

@ -7,11 +7,13 @@ 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() {
this._rocketchatLogger = new Logger('Rocket.Chat Apps');
Permissions.createOrUpdate('manage-apps', ['admin']);
this._marketplaceUrl = 'https://marketplace.rocket.chat';
@ -82,6 +84,10 @@ class AppServerOrchestrator {
return settings.get('Apps_Framework_Development_Mode');
}
getRocketChatLogger() {
return this._rocketchatLogger;
}
debugLog(...args) {
if (this.isDebugging()) {
// eslint-disable-next-line
@ -122,7 +128,8 @@ class AppServerOrchestrator {
return Promise.resolve();
}
return this._manager.updateAppsMarketplaceInfo(apps);
return this._manager.updateAppsMarketplaceInfo(apps)
.then(() => this._manager.get());
}
}

@ -1120,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);

51
package-lock.json generated

@ -675,9 +675,9 @@
"integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA="
},
"@rocket.chat/apps-engine": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/apps-engine/-/apps-engine-1.5.0.tgz",
"integrity": "sha512-WBPbfdNSwJo4wyqU+zjC2erxFxXHgXQZM6rW7eMPmzTaU02MVqyRD4cB48bo7FdJ/AoN9/V/BvknkXjF/PrJxA==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@rocket.chat/apps-engine/-/apps-engine-1.5.1.tgz",
"integrity": "sha512-Nlj5NOxqNo7Ae+1wLD48AXBPIeMiQqUXlYOjRH6o5P9Q5IrTmIA28ZCKjjthUGnso8TB8KDWCPqY9aJWynoOtw==",
"requires": {
"adm-zip": "^0.4.9",
"lodash.clonedeep": "^4.5.0",
@ -4324,13 +4324,13 @@
"chromedriver": "^2.35",
"colors": "1.1.2",
"commander": "^2.9.0",
"cucumber": "github:xolvio/cucumber-js#cf953cb5b5de30dbcc279f59e4ebff3aa040071c",
"cucumber": "github:xolvio/cucumber-js#v1.3.0-chimp.6",
"deep-extend": "^0.4.1",
"exit": "^0.1.2",
"fibers": "^1.0.14",
"freeport": "~1.0.5",
"fs-extra": "^1.0.0",
"glob": "github:lucetius/node-glob#51c7ca6e69bfbd17db5f1ea710e3f2a7a457d9ce",
"glob": "github:lucetius/node-glob#chimp",
"hapi": "8.8.0",
"jasmine": "^2.4.1",
"loglevel": "~1.4.0",
@ -7395,7 +7395,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -7416,12 +7417,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -7436,17 +7439,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -7563,7 +7569,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -7575,6 +7582,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -7589,6 +7597,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -7596,12 +7605,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -7620,6 +7631,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -7707,7 +7719,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -7719,6 +7732,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -7804,7 +7818,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -7840,6 +7855,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -7859,6 +7875,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -7902,12 +7919,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},

@ -133,7 +133,7 @@
"@google-cloud/language": "^2.0.0",
"@google-cloud/storage": "^2.3.1",
"@google-cloud/vision": "^0.23.0",
"@rocket.chat/apps-engine": "^1.5.0",
"@rocket.chat/apps-engine": "^1.5.1",
"@slack/client": "^4.8.0",
"adm-zip": "^0.4.13",
"apollo-server-express": "^1.3.6",

@ -338,6 +338,7 @@
"App_status_disabled": "Disabled",
"App_status_error_disabled": "Disabled: Uncaught Error",
"App_status_initialized": "Initialized",
"App_status_invalid_license_disabled": "Disabled: Invalid License",
"App_status_invalid_settings_disabled": "Disabled: Configuration Needed",
"App_status_manually_disabled": "Disabled: Manually",
"App_status_manually_enabled": "Enabled",
@ -356,8 +357,11 @@
"Apps_Framework_Development_Mode": "Enable development mode",
"Apps_Framework_Development_Mode_Description": "Development mode allows the installation of Apps that are not from the Rocket.Chat's Marketplace.",
"Apps_Framework_enabled": "Enable the App Framework",
"Apps_Marketplace_Deactivate_App_Prompt": "Do you really want to deactivate this app?",
"Apps_Marketplace_Deactivate_App_Prompt": "Do you really want to disable this app?",
"Apps_Marketplace_Modify_App_Subscription": "Modify Subscription",
"Apps_Marketplace_Uninstall_App_Prompt": "Do you really want to uninstall this app?",
"Apps_Marketplace_Uninstall_Subscribed_App_Anyway": "Uninstall it anyway",
"Apps_Marketplace_Uninstall_Subscribed_App_Prompt": "This app has an active subscription and uninstalling will not cancel it. If you'd like to do that, please modify your subscription before uninstalling.",
"Apps_Marketplace_Login_Required_Title": "Marketplace Login Required",
"Apps_Marketplace_Login_Required_Description": "Purchasing apps from the Rocket.Chat Marketplace requires registering your workspace and logging in.",
"Apps_Marketplace_pricingPlan_monthly": "__price__ / month",
@ -1335,6 +1339,7 @@
"External_Queue_Service_URL": "External Queue Service URL",
"External_Service": "External Service",
"Facebook_Page": "Facebook Page",
"Failed": "Failed",
"False": "False",
"Favorite_Rooms": "Enable Favorite Rooms",
"Favorite": "Favorite",
@ -2926,6 +2931,7 @@
"There_are_no_integrations": "There are no integrations",
"There_are_no_personal_access_tokens_created_yet": "There are no Personal Access Tokens created yet.",
"There_are_no_users_in_this_role": "There are no users in this role.",
"There_is_one_or_more_apps_in_an_invalid_state_Click_here_to_review": "There is one or more apps in an invalid state. Click here to review.",
"This_conversation_is_already_closed": "This conversation is already closed.",
"This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password": "This email has already been used and has not been verified. Please change your password.",
"This_is_a_desktop_notification": "This is a desktop notification",
@ -3222,6 +3228,7 @@
"Visitor_page_URL": "Visitor page URL",
"Visitor_time_on_site": "Visitor time on site",
"Wait_activation_warning": "Before you can login, your account must be manually activated by an administrator.",
"Warning": "Warning",
"Warnings": "Warnings",
"We_are_offline_Sorry_for_the_inconvenience": "We are offline. Sorry for the inconvenience.",
"We_have_sent_password_email": "We have sent you an email with password reset instructions. If you do not receive an email shortly, please come back and try again.",

Loading…
Cancel
Save