[NEW] Marketplace integration with Rocket.Chat Cloud (#13809)

* Add the cloud token to the marketplace calls

* Switch the app downloading to the server

* Implement some of the buying an app iframe

* Show when apps are purchased in the list

* Revert the committed change in the marketplace base url

* More work on enabling purchasing

* Install Apps from marketplace

* Missing fixes on rebase

* Improve marketplace design

* Improve view of app’s logs

* Move buttons of app details to header

* Fix mispeling

* Fix purchase/download flow

* Enable checking for updates for an app based upon the version of the engine

* Disable a possible issue with installation of apps

* Perform app downloads with a special short lived token for app download only

* Fix imports

* Fix layout of app details on old chrome and safari

* Re enable upload button adding a setting for development mode

* Change download encoding to binary

* Add iframe loading

* Fix some UI problems
pull/13832/head^2
Rodrigo Nascimento 6 years ago committed by GitHub
parent 7c2d680b32
commit fd817b2b14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 77
      app/apps/assets/stylesheets/apps.css
  2. 51
      app/apps/client/admin/appLogs.html
  3. 55
      app/apps/client/admin/appLogs.js
  4. 23
      app/apps/client/admin/appManage.html
  5. 197
      app/apps/client/admin/appManage.js
  6. 74
      app/apps/client/admin/apps.html
  7. 128
      app/apps/client/admin/apps.js
  8. 7
      app/apps/client/admin/modalTemplates/iframeModal.html
  9. 48
      app/apps/client/admin/modalTemplates/iframeModal.js
  10. 3
      app/apps/client/index.js
  11. 6
      app/apps/server/bridges/api.js
  12. 12
      app/apps/server/bridges/commands.js
  13. 6
      app/apps/server/bridges/environmental.js
  14. 2
      app/apps/server/bridges/http.js
  15. 8
      app/apps/server/bridges/listeners.js
  16. 14
      app/apps/server/bridges/messages.js
  17. 18
      app/apps/server/bridges/persistence.js
  18. 16
      app/apps/server/bridges/rooms.js
  19. 12
      app/apps/server/bridges/settings.js
  20. 4
      app/apps/server/bridges/users.js
  21. 172
      app/apps/server/communication/rest.js
  22. 28
      app/apps/server/orchestrator.js
  23. 16
      app/cloud/server/functions/getWorkspaceAccessToken.js
  24. 5
      app/theme/client/imports/components/modal/directory.css
  25. 6
      app/ui/client/components/header/header.html
  26. 10
      packages/rocketchat-i18n/i18n/en.i18n.json

@ -83,6 +83,7 @@
} }
.rc-apps-container { .rc-apps-container {
margin-top: 0;
padding-bottom: 15px; padding-bottom: 15px;
} }
@ -97,9 +98,20 @@
margin-top: 6px; margin-top: 6px;
} */ } */
.rc-header .rc-button { .content {
min-height: 0; /* display: block !important; */
margin: 0; padding: 0 !important;
> .rc-apps-container {
display: block;
overflow-y: scroll;
padding: 0 !important;
}
> .rc-apps-details {
display: block;
}
} }
.rc-apps-category { .rc-apps-category {
@ -143,19 +155,56 @@
} }
.rc-table-info { .rc-table-info {
height: 40px;
margin: 0 7px; margin: 0 7px;
} }
.rc-app-price {
position: relative;
top: -3px;
}
.rc-table-td--medium {
width: 300px;
}
.rc-table td {
padding: 0.5rem 0;
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 { &__wrap-actions {
& > .rc-icon--loading { & > .loading {
display: none; display: none;
} }
&.loading { &.loading {
& > .rc-icon--loading { & > .loading {
display: block; display: block;
animation: spin 1s linear infinite; font-size: 11px;
font-weight: 600;
& > .rc-icon--loading {
animation: spin 1s linear infinite;
}
} }
& > .apps-installer { & > .apps-installer {
@ -163,6 +212,22 @@
} }
} }
} }
.arrow-up {
transform: rotate(180deg);
}
&.page-settings {
.section {
padding: 0 !important;
border-bottom: none !important;
&:hover {
background-color: var(--rc-color-primary-lightest);
}
}
}
} }
@keyframes play90 { @keyframes play90 {

@ -1,24 +1,21 @@
<template name="appLogs"> <template name="appLogs">
{{#with app}} {{#with app}}
<section class="page-container page-list page-settings flex-tab-main-content"> <section class="page-container page-list page-settings flex-tab-main-content rc-apps-marketplace">
<header class="fixed-title"> {{# header rawSectionName=title fixedHeight=true hideHelp=true fullpage=true}}
{{> burger}} <div class="rc-header__block rc-header__block-action">
<a href="{{pathFor "app-manage" appId=id}}" title="{{_ "Back_to_Manage_Apps"}}"> <button class="rc-button rc-button--primary js-refresh">
<i class="icon-left-open"></i> {{> icon icon="reload" block="rc-icon--default-size"}} {{_ "Refresh"}}
</a> &nbsp; </button>
<button class="rc-button rc-button--nude js-cancel">{{> icon icon="cross"}}</button>
{{#if isReady}} </div>
<h2> {{/header}}
<span class="room-title">{{_ "View_the_Logs_for" name=name}}</span> <div class="content">
</h2>
{{/if}}
{{#if hasError}} {{#if hasError}}
<h2> <h2>
<span class="room-title">{{ theError }}</span> <span class="room-title">{{ theError }}</span>
</h2> </h2>
{{/if}} {{/if}}
</header>
<div class="content">
{{#if isReady}} {{#if isReady}}
<div class="rocket-form"> <div class="rocket-form">
{{#each log in logs}} {{#each log in logs}}
@ -28,30 +25,18 @@
{{formatDate log._createdAt}}: "{{log.method}}" ({{log.totalTime}}ms) {{formatDate log._createdAt}}: "{{log.method}}" ({{log.totalTime}}ms)
</div> </div>
<div class="section-title-right"> <div class="section-title-right">
<button class="button primary expand"> <button class="rc-button rc-button--nude button-down">
<span>{{_ "Expand"}}</span> {{> icon icon="arrow-down" block="rc-icon--default-size"}}
</button> </button>
</div> </div>
</div> </div>
<div class="section-content"> <div class="section-content">
<h4>General Information</h4> {{#each entry in log.entries}}
<ul> <div>{{ entry.severity }}: {{ entry.timestamp }} (Caller: {{ entry.caller }})</div>
<li>Method: {{ log.method }}</li> <div>
<li>Start Time: {{ log.startTime }}</li> <pre><code class="code-colors hljs json">{{{ jsonStringify entry.args }}}</code></pre>
<li>End Time: {{ log.endTime }}</li> </div>
<li>Total Time: {{ log.totalTime }}ms</li> {{/each}}
<li>Log Entries: {{ log.entries.length }}</li>
</ul>
<h4>Log Entries</h4>
<ul>
{{#each entry in log.entries}}
<li>Timestamp: {{ entry.timestamp }}</li>
<li>Severity: {{ entry.severity }}</li>
<li>Caller: {{ entry.caller }}</li>
<li>Arguments: <pre><code class="code-colors hljs json">{{{ jsonStringify entry.args }}}</code></pre></li>
{{/each}}
</ul>
</div> </div>
</div> </div>
{{/each}} {{/each}}

@ -6,30 +6,32 @@ import { APIClient } from '../../../utils';
import moment from 'moment'; import moment from 'moment';
import hljs from 'highlight.js'; import hljs from 'highlight.js';
Template.appLogs.onCreated(function() { const loadData = (instance) => {
const instance = this;
this.id = new ReactiveVar(FlowRouter.getParam('appId'));
this.ready = new ReactiveVar(false);
this.hasError = new ReactiveVar(false);
this.theError = new ReactiveVar('');
this.app = new ReactiveVar({});
this.logs = new ReactiveVar([]);
const id = this.id.get();
Promise.all([ Promise.all([
APIClient.get(`apps/${ id }`), APIClient.get(`apps/${ instance.id.get() }`),
APIClient.get(`apps/${ id }/logs`), APIClient.get(`apps/${ instance.id.get() }/logs`),
]).then((results) => { ]).then((results) => {
instance.app.set(results[0].app); instance.app.set(results[0].app);
instance.logs.set(results[1].logs); instance.logs.set(results[1].logs);
this.ready.set(true); instance.ready.set(true);
}).catch((e) => { }).catch((e) => {
instance.hasError.set(true); instance.hasError.set(true);
instance.theError.set(e.message); instance.theError.set(e.message);
}); });
};
Template.appLogs.onCreated(function() {
const instance = this;
this.id = new ReactiveVar(FlowRouter.getParam('appId'));
this.ready = new ReactiveVar(false);
this.hasError = new ReactiveVar(false);
this.theError = new ReactiveVar('');
this.app = new ReactiveVar({});
this.logs = new ReactiveVar([]);
loadData(instance);
}); });
Template.appLogs.helpers({ Template.appLogs.helpers({
@ -76,16 +78,29 @@ Template.appLogs.helpers({
return value.replace(/\\\\n/g, '<br>'); return value.replace(/\\\\n/g, '<br>');
}, },
title() {
return TAPi18n.__('View_the_Logs_for', { name: Template.instance().app.get().name });
},
}); });
Template.appLogs.events({ Template.appLogs.events({
'click .expand': (e) => { 'click .section-collapsed .section-title': (e) => {
$(e.currentTarget).closest('.section').removeClass('section-collapsed'); $(e.currentTarget).closest('.section').removeClass('section-collapsed').addClass('section-expanded');
$(e.currentTarget).closest('button').removeClass('expand').addClass('collapse').find('span').text(TAPi18n.__('Collapse')); $(e.currentTarget).find('.button-down').addClass('arrow-up');
},
'click .section-expanded .section-title': (e) => {
$(e.currentTarget).closest('.section').removeClass('section-expanded').addClass('section-collapsed');
$(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 .collapse': (e) => { 'click .js-refresh': (e, t) => {
$(e.currentTarget).closest('.section').addClass('section-collapsed'); t.ready.set(false);
$(e.currentTarget).closest('button').addClass('expand').removeClass('collapse').find('span').text(TAPi18n.__('Expand')); t.logs.set([]);
loadData(t);
}, },
}); });

@ -3,6 +3,13 @@
{{#with app}} {{#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-marketplace">
{{# header sectionName='App_Details' fixedHeight=true hideHelp=true fullpage=true}} {{# 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>
<div class="rc-header__block rc-header__block-action"> <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-cancel">{{> icon icon="cross"}}</button>
</div> </div>
@ -18,7 +25,7 @@
<div class="rc-apps-details"> <div class="rc-apps-details">
<div class="rc-apps-container rc-apps-container__header"> <div class="rc-apps-container rc-apps-container__header">
{{#if iconFileData}} {{#if iconFileData}}
<div class="rc-apps-details__photo" style="background-image:url(data:image/png;base64,{{iconFileData}})"></div> <div class="rc-apps-details__photo" style="background-image:url(data:image/png;base64,{{iconFileData}})"></div>
{{else}} {{else}}
<div class="rc-apps-details__photo" style="background-image:url({{iconFileContent}})"></div> <div class="rc-apps-details__photo" style="background-image:url({{iconFileContent}})"></div>
{{/if}} {{/if}}
@ -44,7 +51,15 @@
{{/if}} {{/if}}
<button class="rc-button rc-button--nude js-view-logs">{{> icon icon="list-alt"}} {{_ "View_Logs" }}</button> <button class="rc-button rc-button--nude js-view-logs">{{> icon icon="list-alt"}} {{_ "View_Logs" }}</button>
{{else}} {{else}}
<button class="rc-button rc-button--primary js-install">{{> icon icon="circled-arrow-down"}} {{_ "Install"}}</button> {{#if hasPurchased}}
<button class="rc-button rc-button--primary js-install">{{> icon icon="download"}} {{_ "Purchased"}}</button>
{{else}}
{{#if $eq price 0}}
<button class="rc-button rc-button--primary js-purchase">{{> icon icon="circled-arrow-down"}} {{_ "Free"}}</button>
{{else}}
<button class="rc-button rc-button--primary js-purchase">{{> icon icon="circled-arrow-down"}} {{displayPrice}}</button>
{{/if}}
{{/if}}
{{/if}} {{/if}}
</div> </div>
</div> </div>
@ -454,10 +469,6 @@
</div> </div>
{{/each}} {{/each}}
<div class="rc-button-group">
<button class="rc-button rc-button--outline js-cancel-editing" disabled='{{disabled}}'>{{_ "Cancel" }}</button>
<button class="rc-button rc-button--primary js-save {{#if saving}} loading{{/if}}" disabled='{{disabled}}'>{{_ "Save" }}</button>
</div>
</div> </div>
{{/if}} {{/if}}
</div> </div>

@ -4,9 +4,10 @@ import { FlowRouter } from 'meteor/kadira:flow-router';
import { Template } from 'meteor/templating'; import { Template } from 'meteor/templating';
import { TAPi18n } from 'meteor/tap:i18n'; import { TAPi18n } from 'meteor/tap:i18n';
import { TAPi18next } from 'meteor/tap:i18n'; import { TAPi18next } from 'meteor/tap:i18n';
import { isEmail, Info, APIClient } from '../../../utils'; import { isEmail, APIClient } from '../../../utils';
import { settings } from '../../../settings'; import { settings } from '../../../settings';
import { Markdown } from '../../../markdown/client'; import { Markdown } from '../../../markdown/client';
import { modal } from '../../../ui-utils';
import _ from 'underscore'; import _ from 'underscore';
import s from 'underscore.string'; import s from 'underscore.string';
import toastr from 'toastr'; import toastr from 'toastr';
@ -16,55 +17,111 @@ import { Utilities } from '../../lib/misc/Utilities';
import { Apps } from '../orchestrator'; import { Apps } from '../orchestrator';
import semver from 'semver'; import semver from 'semver';
const HOST = 'https://marketplace.rocket.chat'; // TODO move this to inside RocketChat.API function getApps(instance) {
async function getApps(instance) {
const id = instance.id.get(); const id = instance.id.get();
let remoteApps;
let localApp; const appInfo = { remote: undefined, local: undefined };
try { return APIClient.get(`apps/${ id }?marketplace=true&version=${ FlowRouter.getQueryParam('version') }`)
localApp = (await APIClient.get('apps/')).apps.filter((app) => app.id === id)[0]; .catch((e) => {
remoteApps = await fetch(`${ HOST }/v1/apps/${ id }?version=${ Info.marketplaceApiVersion }`).then((data) => data.json()); console.log(e);
} catch (error) { return Promise.resolve({ app: undefined });
if (!localApp) { })
.then((remote) => {
appInfo.remote = remote.app;
return APIClient.get(`apps/${ id }`);
})
.then((local) => {
appInfo.local = local.app;
return Apps.getAppApis(id);
})
.then((apis) => instance.apis.set(apis))
.catch((e) => {
if (appInfo.remote || appInfo.local) {
return Promise.resolve(true);
}
instance.hasError.set(true); instance.hasError.set(true);
instance.theError.set(error.message); instance.theError.set(e.message);
} }).then((goOn) => {
} if (typeof goOn !== 'undefined' && !goOn) {
let remoteApp; return;
if (remoteApps && remoteApps.length) {
remoteApps = remoteApps.sort((a, b) => {
if (semver.gt(a.version, b.version)) {
return -1;
} }
if (semver.lt(a.version, b.version)) {
return 1; if (appInfo.remote) {
appInfo.remote.displayPrice = parseFloat(appInfo.remote.price).toFixed(2);
} }
return 0;
});
remoteApp = remoteApps[0];
}
if (localApp) { if (appInfo.local) {
localApp.installed = true; appInfo.local.installed = true;
if (remoteApp) {
localApp.categories = remoteApp.categories; if (appInfo.remote) {
if (semver.gt(remoteApp.version, localApp.version)) { appInfo.local.categories = appInfo.remote.categories;
localApp.newVersion = remoteApp.version; appInfo.local.isPurchased = appInfo.remote.isPurchased;
appInfo.local.price = appInfo.remote.price;
appInfo.local.displayPrice = appInfo.remote.displayPrice;
if (semver.gt(appInfo.remote.version, appInfo.local.version) && (appInfo.remote.isPurchased || appInfo.remote.price <= 0)) {
appInfo.local.newVersion = appInfo.remote.version;
}
}
instance.onSettingUpdated({ appId: id });
Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged);
Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated);
Apps.getWsListener().registerListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged);
Apps.getWsListener().registerListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated);
} }
}
instance.onSettingUpdated({ appId: id }); instance.app.set(appInfo.local || appInfo.remote);
instance.ready.set(true);
Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); if (appInfo.remote && appInfo.local) {
Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated); return APIClient.get(`apps/${ id }?marketplace=true&update=true&appVersion=${ FlowRouter.getQueryParam('version') }`);
Apps.getWsListener().registerListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); }
Apps.getWsListener().registerListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated);
} return Promise.resolve(false);
}).then((updateInfo) => {
if (!updateInfo) {
return;
}
const update = updateInfo.app;
instance.app.set(localApp || remoteApp); if (semver.gt(update.version, appInfo.local.version) && (update.isPurchased || update.price <= 0)) {
appInfo.local.newVersion = update.version;
instance.ready.set(true); instance.app.set(appInfo.local);
}
});
}
function installAppFromEvent(e, t) {
const el = $(e.currentTarget);
el.prop('disabled', true);
el.addClass('loading');
const app = t.app.get();
const api = app.newVersion ? `apps/${ t.id.get() }` : 'apps/';
APIClient.post(api, {
appId: app.id,
marketplace: true,
version: app.version,
}).then(() => getApps(t)).then(() => {
el.prop('disabled', false);
el.removeClass('loading');
}).catch((e) => {
el.prop('disabled', false);
el.removeClass('loading');
t.hasError.set(true);
t.theError.set((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
});
// play animation
// TODO this icon and animation are not working
$(e.currentTarget).find('.rc-icon').addClass('play');
} }
Template.appManage.onCreated(function() { Template.appManage.onCreated(function() {
@ -81,12 +138,7 @@ Template.appManage.onCreated(function() {
this.loading = new ReactiveVar(false); this.loading = new ReactiveVar(false);
const id = this.id.get(); const id = this.id.get();
getApps(instance);
this.getApis = async() => {
this.apis.set(await Apps.getAppApis(id));
};
this.getApis();
this.__ = (key, options, lang_tag) => { this.__ = (key, options, lang_tag) => {
const appKey = Utilities.getI18nKeyForApp(key, id); const appKey = Utilities.getI18nKeyForApp(key, id);
@ -104,8 +156,6 @@ Template.appManage.onCreated(function() {
instance.settings.set(settings); instance.settings.set(settings);
} }
getApps(instance);
instance.onStatusChanged = function _onStatusChanged({ appId, status }) { instance.onStatusChanged = function _onStatusChanged({ appId, status }) {
if (appId !== id) { if (appId !== id) {
return; return;
@ -223,6 +273,11 @@ Template.appManage.helpers({
return instance.app.get().installed === true; return instance.app.get().installed === true;
}, },
hasPurchased() {
const instance = Template.instance();
return instance.app.get().isPurchased === true;
},
app() { app() {
return Template.instance().app.get(); return Template.instance().app.get();
}, },
@ -318,31 +373,27 @@ Template.appManage.events({
}, },
'click .js-install': async(e, t) => { 'click .js-install': async(e, t) => {
const el = $(e.currentTarget); installAppFromEvent(e, t);
el.prop('disabled', true); },
el.addClass('loading');
'click .js-purchase': (e, t) => {
const app = t.app.get(); const rl = t.app.get();
const url = `${ HOST }/v1/apps/${ t.id.get() }/download/${ app.version }`; APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.id }`)
.then((data) => {
const api = app.newVersion ? `apps/${ t.id.get() }` : 'apps/'; data.successCallback = async() => {
installAppFromEvent(e, t);
APIClient.post(api, { url }).then(() => { };
getApps(t).then(() => {
el.prop('disabled', false); modal.open({
el.removeClass('loading'); allowOutsideClick: false,
data,
template: 'iframeModal',
});
})
.catch((e) => {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
}); });
}).catch((e) => {
el.prop('disabled', false);
el.removeClass('loading');
t.hasError.set(true);
t.theError.set((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
});
// play animation
// TODO this icon and animation are not working
$(e.currentTarget).find('.rc-icon').addClass('play');
}, },
'click .js-update': (e, t) => { 'click .js-update': (e, t) => {
@ -350,7 +401,7 @@ Template.appManage.events({
}, },
'click .js-view-logs': (e, t) => { 'click .js-view-logs': (e, t) => {
FlowRouter.go(`/admin/apps/${ t.id.get() }/logs`); FlowRouter.go(`/admin/apps/${ t.id.get() }/logs`, {}, { version: FlowRouter.getQueryParam('version') });
}, },
'click .js-cancel-editing': async(e, t) => { 'click .js-cancel-editing': async(e, t) => {

@ -1,7 +1,9 @@
<template name="apps"> <template name="apps">
<section class="rc-directory rc-apps-marketplace"> <section class="rc-directory rc-apps-marketplace">
{{#header sectionName="Apps" hideHelp=true fixedHeight=true fullpage=true}} {{#header sectionName="Apps" hideHelp=true fixedHeight=true fullpage=true}}
<button class="rc-button rc-button--small rc-button--primary rc-directory-plus" data-button="install">{{> icon icon="plus"}}</button> {{#if appsDevelopmentMode}}
<button class="rc-button rc-button--small rc-button--primary rc-button--outline rc-directory-plus" data-button="install">{{> icon icon="upload" block="rc-icon--default-size"}} {{_ "Upload app"}}</button>
{{/if}}
{{/header}} {{/header}}
<div class="rc-table-content"> <div class="rc-table-content">
{{>tabs tabs=tabsData}} {{>tabs tabs=tabsData}}
@ -20,13 +22,10 @@
<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 searchSortBy 'name'}}is-sorting{{/if}}" data-sort="name">
<div class="table-fake-th">{{_ "Name"}} {{> icon icon=(sortIcon 'name')}}</div> <div class="table-fake-th">{{_ "Name"}} {{> icon icon=(sortIcon 'name')}}</div>
</th> </th>
<th class="js-sort rc-table-td--medium {{#if searchSortBy 'category'}}is-sorting{{/if}}" data-sort="category">>
<div class="table-fake-th">{{_ "Category"}} {{> icon icon=(sortIcon 'category') }}</div>
</th>
<th class="rc-table-td"> <th class="rc-table-td">
<div class="table-fake-th">{{_ "Details"}} </div> <div class="table-fake-th">{{_ "Details"}} </div>
</th> </th>
<th class="rc-table-td--small"> <th class="rc-table-td--small rc-apps-marketplace-price">
</th> </th>
</tr> </tr>
</thead> </thead>
@ -50,32 +49,57 @@
</div> </div>
</div> </div>
</td> </td>
<td>{{latest.categories}}</td>
<td> <td>
<p class="rc-table-title"> <div class="rc-table-wrapper">
{{#if latest.summary}} <div class="rc-table-info">
{{latest.summary}} <span class="rc-table-title">
{{else}} {{#if latest.summary}}
{{latest.description}} {{latest.summary}}
{{/if}} {{else}}
</p> {{latest.description}}
{{#if latest.summary}} {{/if}}
<p> </span>
{{latest.description}} {{#if latest.summary}}
</p> <span class="rc-table-subtitle">
{{/if}} {{latest.description}}
</span>
{{/if}}
<span class="rc-table-subtitle">
{{formatCategories latest.categories}}
</span>
</div>
</div>
</td> </td>
<td> <td class="rc-apps-marketplace-price">
{{#if $eq latest._installed true}} {{#if $eq latest._installed true}}
{{> icon icon="checkmark-circled" block="rc-icon--default-size"}} <button class="apps-installer" data-app="{{appId}}">
{{_ "Installed"}}
{{> icon icon="menu" block="rc-icon--default-size"}}
</button>
{{/if}} {{/if}}
{{#if renderDownloadButton latest}} {{#if renderDownloadButton latest}}
<div class="rc-apps-marketplace__wrap-actions"> <div class="rc-apps-marketplace__wrap-actions">
{{> icon block="rc-icon--default-size rc-icon" icon="loading"}} {{#if $eq isPurchased true}}
<button class="js-install apps-installer" data-app="{{appId}}"> <button class="js-install apps-installer" data-app="{{appId}}">
{{> icon block="installer rc-icon--default-size" icon="circled-arrow-down"}} {{_ "Purchased"}}
</button> {{> icon block="installer rc-icon--default-size" icon="download"}}
</button>
{{else}}
<button class="js-purchase apps-installer" data-app="{{appId}}">
<span class="rc-app-price">
{{#if $eq price 0}}
{{_ "Free"}}
{{else}}
{{ formatPrice price }}
{{/if}}
</span>
{{> icon block="installer rc-icon--default-size" icon="circled-arrow-down"}}
</button>
{{/if}}
<span class="loading">
Downloading
{{> icon block="rc-icon--default-size rc-icon" icon="loading"}}
</span>
</div> </div>
{{/if}} {{/if}}
</td> </td>

@ -2,12 +2,13 @@ import toastr from 'toastr';
import { ReactiveVar } from 'meteor/reactive-var'; import { ReactiveVar } from 'meteor/reactive-var';
import { FlowRouter } from 'meteor/kadira:flow-router'; import { FlowRouter } from 'meteor/kadira:flow-router';
import { Template } from 'meteor/templating'; import { Template } from 'meteor/templating';
import { t, Info, APIClient } from '../../../utils'; import { settings } from '../../../settings';
import { t, APIClient } from '../../../utils';
import { modal } from '../../../ui-utils';
import { AppEvents } from '../communication'; import { AppEvents } from '../communication';
import { Apps } from '../orchestrator'; import { Apps } from '../orchestrator';
const ENABLED_STATUS = ['auto_enabled', 'manually_enabled']; const ENABLED_STATUS = ['auto_enabled', 'manually_enabled'];
const HOST = 'https://marketplace.rocket.chat';
const enabled = ({ status }) => ENABLED_STATUS.includes(status); const enabled = ({ status }) => ENABLED_STATUS.includes(status);
const sortByColumn = (array, column, inverted) => const sortByColumn = (array, column, inverted) =>
@ -23,6 +24,8 @@ const tagAlreadyInstalledApps = (installedApps, apps) => {
const tagged = apps.map((app) => const tagged = apps.map((app) =>
({ ({
price: app.price,
isPurchased: app.isPurchased,
latest: { latest: {
...app.latest, ...app.latest,
_installed: installedIds.includes(app.latest.id), _installed: installedIds.includes(app.latest.id),
@ -33,31 +36,29 @@ const tagAlreadyInstalledApps = (installedApps, apps) => {
return tagged; return tagged;
}; };
const getApps = (instance) => { const getApps = async(instance) => {
fetch(`${ HOST }/v1/apps?version=${ Info.marketplaceApiVersion }`) instance.isLoading.set(true);
.then((response) => response.json())
.then((data) => {
const tagged = tagAlreadyInstalledApps(instance.installedApps.get(), data);
if (instance.searchType.get() === 'marketplace') { const data = await APIClient.get('apps?marketplace=true');
instance.apps.set(tagged); const tagged = tagAlreadyInstalledApps(instance.installedApps.get(), data);
instance.isLoading.set(false);
instance.ready.set(true); if (instance.searchType.get() === 'marketplace') {
} instance.apps.set(tagged);
}); instance.isLoading.set(false);
instance.ready.set(true);
}
}; };
const getInstalledApps = (instance) => { const getInstalledApps = async(instance) => {
APIClient.get('apps').then((data) => { const data = await APIClient.get('apps');
const apps = data.apps.map((app) => ({ latest: app })); const apps = data.apps.map((app) => ({ latest: app }));
instance.installedApps.set(apps); instance.installedApps.set(apps);
if (instance.searchType.get() === 'installed') { if (instance.searchType.get() === 'installed') {
instance.apps.set(apps); instance.apps.set(apps);
instance.isLoading.set(false); instance.isLoading.set(false);
instance.ready.set(true); instance.ready.set(true);
} }
});
}; };
Template.apps.onCreated(function() { Template.apps.onCreated(function() {
@ -82,14 +83,10 @@ Template.apps.onCreated(function() {
} }
} }
getApps(instance);
getInstalledApps(instance); getInstalledApps(instance);
getApps(instance);
fetch(`${ HOST }/v1/categories`) APIClient.get('apps?categories=true').then((data) => instance.categories.set(data));
.then((response) => response.json())
.then((data) => {
instance.categories.set(data);
});
instance.onAppAdded = function _appOnAppAdded() { instance.onAppAdded = function _appOnAppAdded() {
// ToDo: fix this formatting data to add an app to installedApps array without to fetch all // ToDo: fix this formatting data to add an app to installedApps array without to fetch all
@ -149,6 +146,9 @@ Template.apps.helpers({
categories() { categories() {
return Template.instance().categories.get(); return Template.instance().categories.get();
}, },
appsDevelopmentMode() {
return settings.get('Apps_Framework_Development_Mode') === true;
},
parseStatus(status) { parseStatus(status) {
return t(`App_status_${ status }`); return t(`App_status_${ status }`);
}, },
@ -211,6 +211,12 @@ Template.apps.helpers({
return isMarketplace && isDownloaded; return isMarketplace && isDownloaded;
}, },
formatPrice(price) {
return `$${ Number.parseFloat(price).toFixed(2) }`;
},
formatCategories(categories = []) {
return categories.join(', ');
},
tabsData() { tabsData() {
const instance = Template.instance(); const instance = Template.instance();
@ -256,7 +262,7 @@ Template.apps.events({
const rl = this; const rl = this;
if (rl && rl.latest && rl.latest.id) { if (rl && rl.latest && rl.latest.id) {
FlowRouter.go(`/admin/apps/${ rl.latest.id }`); FlowRouter.go(`/admin/apps/${ rl.latest.id }?version=${ rl.latest.version }`);
} }
}, },
'click [data-button="install"]'() { 'click [data-button="install"]'() {
@ -264,18 +270,64 @@ Template.apps.events({
}, },
'click .js-install'(e, template) { 'click .js-install'(e, template) {
e.stopPropagation(); e.stopPropagation();
const elm = e.currentTarget.parentElement;
const url = `${ HOST }/v1/apps/${ this.latest.id }/download/${ this.latest.version }`; elm.classList.add('loading');
// play animation APIClient.post('apps/', {
e.currentTarget.parentElement.classList.add('loading'); appId: this.latest.id,
marketplace: true,
version: this.latest.version,
})
.then(async() => {
await Promise.all([
getInstalledApps(template),
getApps(template),
]);
elm.classList.remove('loading');
})
.catch((e) => {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
elm.classList.remove('loading');
});
},
'click .js-purchase'(e, template) {
e.stopPropagation();
APIClient.post('apps/', { url }) const rl = this;
.then(() => {
getApps(template); // play animation
getInstalledApps(template); const elm = e.currentTarget.parentElement;
APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.latest.id }`)
.then((data) => {
modal.open({
allowOutsideClick: false,
data,
template: 'iframeModal',
}, () => {
elm.classList.add('loading');
APIClient.post('apps/', {
appId: this.latest.id,
marketplace: true,
version: this.latest.version,
})
.then(async() => {
await Promise.all([
getInstalledApps(template),
getApps(template),
]);
elm.classList.remove('loading');
})
.catch((e) => {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
elm.classList.remove('loading');
});
});
}) })
.catch((e) => toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message)); .catch((e) => {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
});
}, },
'keyup .js-search'(e, t) { 'keyup .js-search'(e, t) {
t.searchText.set(e.currentTarget.value); t.searchText.set(e.currentTarget.value);

@ -0,0 +1,7 @@
<template name="iframeModal">
<main class="rc-modal__content" style="height: 300px; width: 450px;">
<iframe src="{{ data.url }}" style="border: none; display: none; height: 100%; width: 100%;"></iframe>
<div class="loading">{{> loading class="loading-animation--primary"}}</div>
</main>
</template>

@ -0,0 +1,48 @@
import { Template } from 'meteor/templating';
import { modal } from '../../../../ui-utils';
Template.iframeModal.onCreated(function() {
const instance = this;
instance.iframeMsgListener = function _iframeMsgListener(e) {
let data;
try {
data = JSON.parse(e.data);
} catch (e) {
return;
}
if (data.result) {
if (typeof instance.data.successCallback === 'function') {
instance.data.successCallback().then(() => modal.confirm(data));
} else {
modal.confirm(data);
}
} else {
modal.cancel();
}
};
window.addEventListener('message', instance.iframeMsgListener);
});
Template.iframeModal.onRendered(function() {
const iframe = this.firstNode.querySelector('iframe');
const loading = this.firstNode.querySelector('.loading');
iframe.addEventListener('load', () => {
iframe.style.display = 'block';
loading.style.display = 'none';
});
});
Template.iframeModal.onDestroyed(function() {
const instance = this;
window.removeEventListener('message', instance.iframeMsgListener);
});
Template.iframeModal.helpers({
data() {
return Template.instance().data;
},
});

@ -1,4 +1,7 @@
export { Apps } from './orchestrator'; export { Apps } from './orchestrator';
import './admin/modalTemplates/iframeModal.html';
import './admin/modalTemplates/iframeModal';
import './admin/apps.html'; import './admin/apps.html';
import './admin/apps'; import './admin/apps';
import './admin/appInstall.html'; import './admin/appInstall.html';

@ -14,7 +14,7 @@ export class AppApisBridge {
this.appRouters = new Map(); this.appRouters = new Map();
// apiServer.use('/api/apps', (req, res, next) => { // apiServer.use('/api/apps', (req, res, next) => {
// console.log({ // this.orch.debugLog({
// method: req.method.toLowerCase(), // method: req.method.toLowerCase(),
// url: req.url, // url: req.url,
// query: req.query, // query: req.query,
@ -50,7 +50,7 @@ export class AppApisBridge {
} }
registerApi({ api, computedPath, endpoint }, appId) { registerApi({ api, computedPath, endpoint }, appId) {
console.log(`The App ${ appId } is registering the api: "${ endpoint.path }" (${ computedPath })`); this.orch.debugLog(`The App ${ appId } is registering the api: "${ endpoint.path }" (${ computedPath })`);
this._verifyApi(api, endpoint); this._verifyApi(api, endpoint);
@ -71,7 +71,7 @@ export class AppApisBridge {
} }
unregisterApis(appId) { unregisterApis(appId) {
console.log(`The App ${ appId } is unregistering all apis`); this.orch.debugLog(`The App ${ appId } is unregistering all apis`);
if (this.appRouters.get(appId)) { if (this.appRouters.get(appId)) {
this.appRouters.delete(appId); this.appRouters.delete(appId);

@ -10,7 +10,7 @@ export class AppCommandsBridge {
} }
doesCommandExist(command, appId) { doesCommandExist(command, appId) {
console.log(`The App ${ appId } is checking if "${ command }" command exists.`); this.orch.debugLog(`The App ${ appId } is checking if "${ command }" command exists.`);
if (typeof command !== 'string' || command.length === 0) { if (typeof command !== 'string' || command.length === 0) {
return false; return false;
@ -21,7 +21,7 @@ export class AppCommandsBridge {
} }
enableCommand(command, appId) { enableCommand(command, appId) {
console.log(`The App ${ appId } is attempting to enable the command: "${ command }"`); this.orch.debugLog(`The App ${ appId } is attempting to enable the command: "${ command }"`);
if (typeof command !== 'string' || command.trim().length === 0) { if (typeof command !== 'string' || command.trim().length === 0) {
throw new Error('Invalid command parameter provided, must be a string.'); throw new Error('Invalid command parameter provided, must be a string.');
@ -39,7 +39,7 @@ export class AppCommandsBridge {
} }
disableCommand(command, appId) { disableCommand(command, appId) {
console.log(`The App ${ appId } is attempting to disable the command: "${ command }"`); this.orch.debugLog(`The App ${ appId } is attempting to disable the command: "${ command }"`);
if (typeof command !== 'string' || command.trim().length === 0) { if (typeof command !== 'string' || command.trim().length === 0) {
throw new Error('Invalid command parameter provided, must be a string.'); throw new Error('Invalid command parameter provided, must be a string.');
@ -63,7 +63,7 @@ export class AppCommandsBridge {
// command: { command, paramsExample, i18nDescription, executor: function } // command: { command, paramsExample, i18nDescription, executor: function }
modifyCommand(command, appId) { modifyCommand(command, appId) {
console.log(`The App ${ appId } is attempting to modify the command: "${ command }"`); this.orch.debugLog(`The App ${ appId } is attempting to modify the command: "${ command }"`);
this._verifyCommand(command); this._verifyCommand(command);
@ -85,7 +85,7 @@ export class AppCommandsBridge {
} }
registerCommand(command, appId) { registerCommand(command, appId) {
console.log(`The App ${ appId } is registering the command: "${ command.command }"`); this.orch.debugLog(`The App ${ appId } is registering the command: "${ command.command }"`);
this._verifyCommand(command); this._verifyCommand(command);
@ -104,7 +104,7 @@ export class AppCommandsBridge {
} }
unregisterCommand(command, appId) { unregisterCommand(command, appId) {
console.log(`The App ${ appId } is unregistering the command: "${ command }"`); this.orch.debugLog(`The App ${ appId } is unregistering the command: "${ command }"`);
if (typeof command !== 'string' || command.trim().length === 0) { if (typeof command !== 'string' || command.trim().length === 0) {
throw new Error('Invalid command parameter provided, must be a string.'); throw new Error('Invalid command parameter provided, must be a string.');

@ -5,7 +5,7 @@ export class AppEnvironmentalVariableBridge {
} }
async getValueByName(envVarName, appId) { async getValueByName(envVarName, appId) {
console.log(`The App ${ appId } is getting the environmental variable value ${ envVarName }.`); this.orch.debugLog(`The App ${ appId } is getting the environmental variable value ${ envVarName }.`);
if (!(await this.isReadable(envVarName, appId))) { if (!(await this.isReadable(envVarName, appId))) {
throw new Error(`The environmental variable "${ envVarName }" is not readable.`); throw new Error(`The environmental variable "${ envVarName }" is not readable.`);
@ -15,13 +15,13 @@ export class AppEnvironmentalVariableBridge {
} }
async isReadable(envVarName, appId) { async isReadable(envVarName, appId) {
console.log(`The App ${ appId } is checking if the environmental variable is readable ${ envVarName }.`); this.orch.debugLog(`The App ${ appId } is checking if the environmental variable is readable ${ envVarName }.`);
return this.allowed.includes(envVarName.toUpperCase()); return this.allowed.includes(envVarName.toUpperCase());
} }
async isSet(envVarName, appId) { async isSet(envVarName, appId) {
console.log(`The App ${ appId } is checking if the environmental variable is set ${ envVarName }.`); this.orch.debugLog(`The App ${ appId } is checking if the environmental variable is set ${ envVarName }.`);
if (!(await this.isReadable(envVarName, appId))) { if (!(await this.isReadable(envVarName, appId))) {
throw new Error(`The environmental variable "${ envVarName }" is not readable.`); throw new Error(`The environmental variable "${ envVarName }" is not readable.`);

@ -6,7 +6,7 @@ export class AppHttpBridge {
info.request.content = JSON.stringify(info.request.data); info.request.content = JSON.stringify(info.request.data);
} }
console.log(`The App ${ info.appId } is requesting from the outter webs:`, info); this.orch.debugLog(`The App ${ info.appId } is requesting from the outter webs:`, info);
try { try {
return HTTP.call(info.method, info.url, info.request); return HTTP.call(info.method, info.url, info.request);

@ -15,8 +15,8 @@ export class AppListenerBridge {
// try { // try {
// } catch (e) { // } catch (e) {
// console.log(`${ e.name }: ${ e.message }`); // this.orch.debugLog(`${ e.name }: ${ e.message }`);
// console.log(e.stack); // this.orch.debugLog(e.stack);
// } // }
} }
@ -32,8 +32,8 @@ export class AppListenerBridge {
// try { // try {
// } catch (e) { // } catch (e) {
// console.log(`${ e.name }: ${ e.message }`); // this.orch.debugLog(`${ e.name }: ${ e.message }`);
// console.log(e.stack); // this.orch.debugLog(e.stack);
// } // }
} }
} }

@ -10,7 +10,7 @@ export class AppMessageBridge {
} }
async create(message, appId) { async create(message, appId) {
console.log(`The App ${ appId } is creating a new message.`); this.orch.debugLog(`The App ${ appId } is creating a new message.`);
let msg = this.orch.getConverters().get('messages').convertAppMessage(message); let msg = this.orch.getConverters().get('messages').convertAppMessage(message);
@ -22,13 +22,17 @@ export class AppMessageBridge {
} }
async getById(messageId, appId) { async getById(messageId, appId) {
console.log(`The App ${ appId } is getting the message: "${ messageId }"`); this.orch.debugLog(`The App ${ appId } is getting the message: "${ messageId }"`);
return this.orch.getConverters().get('messages').convertById(messageId); return this.orch.getConverters().get('messages').convertById(messageId);
} }
async update(message, appId) { async update(message, appId) {
console.log(`The App ${ appId } is updating a message.`); this.orch.debugLog(`The App ${ appId } is updating a message.`);
if (!this.updateMessage) {
const { updateMessage } = await import('meteor/rocketchat:lib');
this.updateMessage = updateMessage;
}
if (!message.editor) { if (!message.editor) {
throw new Error('Invalid editor assigned to the message for the update.'); throw new Error('Invalid editor assigned to the message for the update.');
@ -45,7 +49,7 @@ export class AppMessageBridge {
} }
async notifyUser(user, message, appId) { async notifyUser(user, message, appId) {
console.log(`The App ${ appId } is notifying a user.`); this.orch.debugLog(`The App ${ appId } is notifying a user.`);
const msg = this.orch.getConverters().get('messages').convertAppMessage(message); const msg = this.orch.getConverters().get('messages').convertAppMessage(message);
@ -58,7 +62,7 @@ export class AppMessageBridge {
} }
async notifyRoom(room, message, appId) { async notifyRoom(room, message, appId) {
console.log(`The App ${ appId } is notifying a room's users.`); this.orch.debugLog(`The App ${ appId } is notifying a room's users.`);
if (room) { if (room) {
const msg = this.orch.getConverters().get('messages').convertAppMessage(message); const msg = this.orch.getConverters().get('messages').convertAppMessage(message);

@ -4,13 +4,13 @@ export class AppPersistenceBridge {
} }
async purge(appId) { async purge(appId) {
console.log(`The App's persistent storage is being purged: ${ appId }`); this.orch.debugLog(`The App's persistent storage is being purged: ${ appId }`);
this.orch.getPersistenceModel().remove({ appId }); this.orch.getPersistenceModel().remove({ appId });
} }
async create(data, appId) { async create(data, appId) {
console.log(`The App ${ appId } is storing a new object in their persistence.`, data); this.orch.debugLog(`The App ${ appId } is storing a new object in their persistence.`, data);
if (typeof data !== 'object') { if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.'); throw new Error('Attempted to store an invalid data type, it must be an object.');
@ -20,7 +20,7 @@ export class AppPersistenceBridge {
} }
async createWithAssociations(data, associations, appId) { async createWithAssociations(data, associations, appId) {
console.log(`The App ${ appId } is storing a new object in their persistence that is associated with some models.`, data, associations); this.orch.debugLog(`The App ${ appId } is storing a new object in their persistence that is associated with some models.`, data, associations);
if (typeof data !== 'object') { if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.'); throw new Error('Attempted to store an invalid data type, it must be an object.');
@ -30,7 +30,7 @@ export class AppPersistenceBridge {
} }
async readById(id, appId) { async readById(id, appId) {
console.log(`The App ${ appId } is reading their data in their persistence with the id: "${ id }"`); this.orch.debugLog(`The App ${ appId } is reading their data in their persistence with the id: "${ id }"`);
const record = this.orch.getPersistenceModel().findOneById(id); const record = this.orch.getPersistenceModel().findOneById(id);
@ -38,7 +38,7 @@ export class AppPersistenceBridge {
} }
async readByAssociations(associations, appId) { async readByAssociations(associations, appId) {
console.log(`The App ${ appId } is searching for records that are associated with the following:`, associations); this.orch.debugLog(`The App ${ appId } is searching for records that are associated with the following:`, associations);
const records = this.orch.getPersistenceModel().find({ const records = this.orch.getPersistenceModel().find({
appId, appId,
@ -49,7 +49,7 @@ export class AppPersistenceBridge {
} }
async remove(id, appId) { async remove(id, appId) {
console.log(`The App ${ appId } is removing one of their records by the id: "${ id }"`); this.orch.debugLog(`The App ${ appId } is removing one of their records by the id: "${ id }"`);
const record = this.orch.getPersistenceModel().findOne({ _id: id, appId }); const record = this.orch.getPersistenceModel().findOne({ _id: id, appId });
@ -63,7 +63,7 @@ export class AppPersistenceBridge {
} }
async removeByAssociations(associations, appId) { async removeByAssociations(associations, appId) {
console.log(`The App ${ appId } is removing records with the following associations:`, associations); this.orch.debugLog(`The App ${ appId } is removing records with the following associations:`, associations);
const query = { const query = {
appId, appId,
@ -84,7 +84,7 @@ export class AppPersistenceBridge {
} }
async update(id, data, upsert, appId) { async update(id, data, upsert, appId) {
console.log(`The App ${ appId } is updating the record "${ id }" to:`, data); this.orch.debugLog(`The App ${ appId } is updating the record "${ id }" to:`, data);
if (typeof data !== 'object') { if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.'); throw new Error('Attempted to store an invalid data type, it must be an object.');
@ -94,7 +94,7 @@ export class AppPersistenceBridge {
} }
async updateByAssociations(associations, data, upsert, appId) { async updateByAssociations(associations, data, upsert, appId) {
console.log(`The App ${ appId } is updating the record with association to data as follows:`, associations, data); this.orch.debugLog(`The App ${ appId } is updating the record with association to data as follows:`, associations, data);
if (typeof data !== 'object') { if (typeof data !== 'object') {
throw new Error('Attempted to store an invalid data type, it must be an object.'); throw new Error('Attempted to store an invalid data type, it must be an object.');

@ -9,7 +9,7 @@ export class AppRoomBridge {
} }
async create(room, members, appId) { async create(room, members, appId) {
console.log(`The App ${ appId } is creating a new room.`, room); this.orch.debugLog(`The App ${ appId } is creating a new room.`, room);
const rcRoom = this.orch.getConverters().get('rooms').convertAppRoom(room); const rcRoom = this.orch.getConverters().get('rooms').convertAppRoom(room);
let method; let method;
@ -49,19 +49,19 @@ export class AppRoomBridge {
} }
async getById(roomId, appId) { async getById(roomId, appId) {
console.log(`The App ${ appId } is getting the roomById: "${ roomId }"`); this.orch.debugLog(`The App ${ appId } is getting the roomById: "${ roomId }"`);
return this.orch.getConverters().get('rooms').convertById(roomId); return this.orch.getConverters().get('rooms').convertById(roomId);
} }
async getByName(roomName, appId) { async getByName(roomName, appId) {
console.log(`The App ${ appId } is getting the roomByName: "${ roomName }"`); this.orch.debugLog(`The App ${ appId } is getting the roomByName: "${ roomName }"`);
return this.orch.getConverters().get('rooms').convertByName(roomName); return this.orch.getConverters().get('rooms').convertByName(roomName);
} }
async getCreatorById(roomId, appId) { async getCreatorById(roomId, appId) {
console.log(`The App ${ appId } is getting the room's creator by id: "${ roomId }"`); this.orch.debugLog(`The App ${ appId } is getting the room's creator by id: "${ roomId }"`);
const room = Rooms.findOneById(roomId); const room = Rooms.findOneById(roomId);
@ -73,7 +73,7 @@ export class AppRoomBridge {
} }
async getCreatorByName(roomName, appId) { async getCreatorByName(roomName, appId) {
console.log(`The App ${ appId } is getting the room's creator by name: "${ roomName }"`); this.orch.debugLog(`The App ${ appId } is getting the room's creator by name: "${ roomName }"`);
const room = Rooms.findOneByName(roomName); const room = Rooms.findOneByName(roomName);
@ -85,13 +85,13 @@ export class AppRoomBridge {
} }
async getMembers(roomId, appId) { async getMembers(roomId, appId) {
console.log(`The App ${ appId } is getting the room's members by room id: "${ roomId }"`); this.orch.debugLog(`The App ${ appId } is getting the room's members by room id: "${ roomId }"`);
const subscriptions = await Subscriptions.findByRoomId(roomId); const subscriptions = await Subscriptions.findByRoomId(roomId);
return subscriptions.map((sub) => this.orch.getConverters().get('users').convertById(sub.u && sub.u._id)); return subscriptions.map((sub) => this.orch.getConverters().get('users').convertById(sub.u && sub.u._id));
} }
async getDirectByUsernames(usernames, appId) { async getDirectByUsernames(usernames, appId) {
console.log(`The App ${ appId } is getting direct room by usernames: "${ usernames }"`); this.orch.debugLog(`The App ${ appId } is getting direct room by usernames: "${ usernames }"`);
const room = await Rooms.findDirectRoomContainingAllUsernames(usernames); const room = await Rooms.findDirectRoomContainingAllUsernames(usernames);
if (!room) { if (!room) {
return undefined; return undefined;
@ -100,7 +100,7 @@ export class AppRoomBridge {
} }
async update(room, members = [], appId) { async update(room, members = [], appId) {
console.log(`The App ${ appId } is updating a room.`); this.orch.debugLog(`The App ${ appId } is updating a room.`);
if (!room.id || !Rooms.findOneById(room.id)) { if (!room.id || !Rooms.findOneById(room.id)) {
throw new Error('A room must exist to update.'); throw new Error('A room must exist to update.');

@ -22,7 +22,7 @@ export class AppSettingBridge {
} }
async getAll(appId) { async getAll(appId) {
console.log(`The App ${ appId } is getting all the settings.`); this.orch.debugLog(`The App ${ appId } is getting all the settings.`);
return Settings.find({ _id: { $nin: this.disallowedSettings } }) return Settings.find({ _id: { $nin: this.disallowedSettings } })
.fetch() .fetch()
@ -30,7 +30,7 @@ export class AppSettingBridge {
} }
async getOneById(id, appId) { async getOneById(id, appId) {
console.log(`The App ${ appId } is getting the setting by id ${ id }.`); this.orch.debugLog(`The App ${ appId } is getting the setting by id ${ id }.`);
if (!this.isReadableById(id, appId)) { if (!this.isReadableById(id, appId)) {
throw new Error(`The setting "${ id }" is not readable.`); throw new Error(`The setting "${ id }" is not readable.`);
@ -40,13 +40,13 @@ export class AppSettingBridge {
} }
async hideGroup(name, appId) { async hideGroup(name, appId) {
console.log(`The App ${ appId } is hidding the group ${ name }.`); this.orch.debugLog(`The App ${ appId } is hidding the group ${ name }.`);
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
async hideSetting(id, appId) { async hideSetting(id, appId) {
console.log(`The App ${ appId } is hidding the setting ${ id }.`); this.orch.debugLog(`The App ${ appId } is hidding the setting ${ id }.`);
if (!this.isReadableById(id, appId)) { if (!this.isReadableById(id, appId)) {
throw new Error(`The setting "${ id }" is not readable.`); throw new Error(`The setting "${ id }" is not readable.`);
@ -56,13 +56,13 @@ export class AppSettingBridge {
} }
async isReadableById(id, appId) { async isReadableById(id, appId) {
console.log(`The App ${ appId } is checking if they can read the setting ${ id }.`); this.orch.debugLog(`The App ${ appId } is checking if they can read the setting ${ id }.`);
return !this.disallowedSettings.includes(id); return !this.disallowedSettings.includes(id);
} }
async updateOne(setting, appId) { async updateOne(setting, appId) {
console.log(`The App ${ appId } is updating the setting ${ setting.id } .`); this.orch.debugLog(`The App ${ appId } is updating the setting ${ setting.id } .`);
if (!this.isReadableById(setting.id, appId)) { if (!this.isReadableById(setting.id, appId)) {
throw new Error(`The setting "${ setting.id }" is not readable.`); throw new Error(`The setting "${ setting.id }" is not readable.`);

@ -4,13 +4,13 @@ export class AppUserBridge {
} }
async getById(userId, appId) { async getById(userId, appId) {
console.log(`The App ${ appId } is getting the userId: "${ userId }"`); this.orch.debugLog(`The App ${ appId } is getting the userId: "${ userId }"`);
return this.orch.getConverters().get('users').convertById(userId); return this.orch.getConverters().get('users').convertById(userId);
} }
async getByUsername(username, appId) { async getByUsername(username, appId) {
console.log(`The App ${ appId } is getting the username: "${ username }"`); this.orch.debugLog(`The App ${ appId } is getting the username: "${ username }"`);
return this.orch.getConverters().get('users').convertByUsername(username); return this.orch.getConverters().get('users').convertByUsername(username);
} }

@ -1,8 +1,12 @@
import { Meteor } from 'meteor/meteor'; import { Meteor } from 'meteor/meteor';
import { HTTP } from 'meteor/http'; import { HTTP } from 'meteor/http';
import { API } from '../../../api/server/api'; import { API } from '../../../api/server';
import Busboy from 'busboy'; import Busboy from 'busboy';
import { getWorkspaceAccessToken } from '../../../cloud/server';
import { settings } from '../../../settings';
import { Info } from '../../../utils';
export class AppsRestApi { export class AppsRestApi {
constructor(orch, manager) { constructor(orch, manager) {
this._orch = orch; this._orch = orch;
@ -49,6 +53,50 @@ export class AppsRestApi {
this.api.addRoute('', { authRequired: true, permissionsRequired: ['manage-apps'] }, { this.api.addRoute('', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() { get() {
const baseUrl = settings.get('Apps_Framework_Marketplace_Url');
// Gets the Apps from the marketplace
if (this.queryParams.marketplace) {
const headers = {};
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
}
const result = HTTP.get(`${ baseUrl }/v1/apps?version=${ Info.marketplaceApiVersion }`, {
headers,
});
if (result.statusCode !== 200) {
return API.v1.failure();
}
return API.v1.success(result.data);
}
if (this.queryParams.categories) {
const headers = {};
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
}
const result = HTTP.get(`${ baseUrl }/v1/categories`, {
headers,
});
if (result.statusCode !== 200) {
return API.v1.failure();
}
return API.v1.success(result.data);
}
if (this.queryParams.buildBuyUrl && this.queryParams.appId) {
const workspaceId = settings.get('Cloud_Workspace_Id');
return API.v1.success({ url: `${ baseUrl }/apps/${ this.queryParams.appId }/buy?workspaceId=${ workspaceId }` });
}
const apps = manager.get().map((prl) => { const apps = manager.get().map((prl) => {
const info = prl.getInfo(); const info = prl.getInfo();
info.languages = prl.getStorageItem().languageContent; info.languages = prl.getStorageItem().languageContent;
@ -63,14 +111,42 @@ export class AppsRestApi {
let buff; let buff;
if (this.bodyParams.url) { if (this.bodyParams.url) {
const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'base64' } }); if (settings.get('Apps_Framework_Development_Mode') !== true) {
return API.v1.failure({ error: 'Installation from url is disabled.' });
}
const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'binary' } });
if (result.statusCode !== 200 || !result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { 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".' }); return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' });
} }
buff = Buffer.from(result.content, 'base64'); buff = Buffer.from(result.content, 'binary');
} else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) {
const baseUrl = settings.get('Apps_Framework_Marketplace_Url');
const headers = {};
const token = getWorkspaceAccessToken(true, 'marketplace:download', false);
const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }?token=${ token }`, {
headers,
npmRequestOptions: { encoding: 'binary' },
});
if (result.statusCode !== 200) {
return API.v1.failure();
}
if (!result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') {
return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' });
}
buff = Buffer.from(result.content, 'binary');
} else { } else {
if (settings.get('Apps_Framework_Development_Mode') !== true) {
return API.v1.failure({ error: 'Direct installation of an App is disabled.' });
}
buff = fileHandler(this.request, 'app'); buff = fileHandler(this.request, 'app');
} }
@ -109,7 +185,46 @@ export class AppsRestApi {
this.api.addRoute(':id', { authRequired: true, permissionsRequired: ['manage-apps'] }, { this.api.addRoute(':id', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() { get() {
console.log('Getting:', this.urlParams.id); if (this.queryParams.marketplace && this.queryParams.version) {
const baseUrl = settings.get('Apps_Framework_Marketplace_Url');
const headers = {};
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
}
const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.urlParams.id }?appVersion=${ this.queryParams.version }`, {
headers,
});
if (result.statusCode !== 200 || result.data.length === 0) {
return API.v1.failure();
}
return API.v1.success({ app: result.data[0] });
}
if (this.queryParams.marketplace && this.queryParams.update && this.queryParams.appVersion) {
const baseUrl = settings.get('Apps_Framework_Marketplace_Url');
const headers = {};
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
}
const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.urlParams.id }/latest?frameworkVersion=${ Info.marketplaceApiVersion }`, {
headers,
});
if (result.statusCode !== 200 || result.data.length === 0) {
return API.v1.failure();
}
return API.v1.success({ app: result.data });
}
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {
@ -122,20 +237,50 @@ export class AppsRestApi {
} }
}, },
post() { post() {
console.log('Updating:', this.urlParams.id);
// TODO: Verify permissions // TODO: Verify permissions
let buff; let buff;
if (this.bodyParams.url) { if (this.bodyParams.url) {
const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'base64' } }); if (settings.get('Apps_Framework_Development_Mode') !== true) {
return API.v1.failure({ error: 'Updating an App from a url is disabled.' });
}
const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'binary' } });
if (result.statusCode !== 200 || !result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') { 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".' }); return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' });
} }
buff = Buffer.from(result.content, 'base64'); buff = Buffer.from(result.content, 'binary');
} else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) {
const baseUrl = settings.get('Apps_Framework_Marketplace_Url');
const headers = {};
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
}
const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }`, {
headers,
npmRequestOptions: { encoding: 'binary' },
});
if (result.statusCode !== 200) {
return API.v1.failure();
}
if (!result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') {
return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' });
}
buff = Buffer.from(result.content, 'binary');
} else { } else {
if (settings.get('Apps_Framework_Development_Mode') !== true) {
return API.v1.failure({ error: 'Direct updating of an App is disabled.' });
}
buff = fileHandler(this.request, 'app'); buff = fileHandler(this.request, 'app');
} }
@ -160,7 +305,6 @@ export class AppsRestApi {
}); });
}, },
delete() { delete() {
console.log('Uninstalling:', this.urlParams.id);
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {
@ -178,7 +322,6 @@ export class AppsRestApi {
this.api.addRoute(':id/icon', { authRequired: true, permissionsRequired: ['manage-apps'] }, { this.api.addRoute(':id/icon', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() { get() {
console.log('Getting the App\'s Icon:', this.urlParams.id);
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {
@ -193,7 +336,6 @@ export class AppsRestApi {
this.api.addRoute(':id/languages', { authRequired: false }, { this.api.addRoute(':id/languages', { authRequired: false }, {
get() { get() {
console.log(`Getting ${ this.urlParams.id }'s languages..`);
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {
@ -208,7 +350,6 @@ export class AppsRestApi {
this.api.addRoute(':id/logs', { authRequired: true, permissionsRequired: ['manage-apps'] }, { this.api.addRoute(':id/logs', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() { get() {
console.log(`Getting ${ this.urlParams.id }'s logs..`);
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {
@ -234,7 +375,6 @@ export class AppsRestApi {
this.api.addRoute(':id/settings', { authRequired: true, permissionsRequired: ['manage-apps'] }, { this.api.addRoute(':id/settings', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() { get() {
console.log(`Getting ${ this.urlParams.id }'s settings..`);
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {
@ -252,7 +392,6 @@ export class AppsRestApi {
} }
}, },
post() { post() {
console.log(`Updating ${ this.urlParams.id }'s settings..`);
if (!this.bodyParams || !this.bodyParams.settings) { if (!this.bodyParams || !this.bodyParams.settings) {
return API.v1.failure('The settings to update must be present.'); return API.v1.failure('The settings to update must be present.');
} }
@ -280,8 +419,6 @@ export class AppsRestApi {
this.api.addRoute(':id/settings/:settingId', { authRequired: true, permissionsRequired: ['manage-apps'] }, { this.api.addRoute(':id/settings/:settingId', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() { get() {
console.log(`Getting the App ${ this.urlParams.id }'s setting ${ this.urlParams.settingId }`);
try { try {
const setting = manager.getSettingsManager().getAppSetting(this.urlParams.id, this.urlParams.settingId); const setting = manager.getSettingsManager().getAppSetting(this.urlParams.id, this.urlParams.settingId);
@ -297,8 +434,6 @@ export class AppsRestApi {
} }
}, },
post() { post() {
console.log(`Updating the App ${ this.urlParams.id }'s setting ${ this.urlParams.settingId }`);
if (!this.bodyParams.setting) { if (!this.bodyParams.setting) {
return API.v1.failure('Setting to update to must be present on the posted body.'); return API.v1.failure('Setting to update to must be present on the posted body.');
} }
@ -321,7 +456,6 @@ export class AppsRestApi {
this.api.addRoute(':id/apis', { authRequired: true, permissionsRequired: ['manage-apps'] }, { this.api.addRoute(':id/apis', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() { get() {
console.log(`Getting ${ this.urlParams.id }'s apis..`);
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {
@ -336,7 +470,6 @@ export class AppsRestApi {
this.api.addRoute(':id/status', { authRequired: true, permissionsRequired: ['manage-apps'] }, { this.api.addRoute(':id/status', { authRequired: true, permissionsRequired: ['manage-apps'] }, {
get() { get() {
console.log(`Getting ${ this.urlParams.id }'s status..`);
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {
@ -350,7 +483,6 @@ export class AppsRestApi {
return API.v1.failure('Invalid status provided, it must be "status" field and a string.'); return API.v1.failure('Invalid status provided, it must be "status" field and a string.');
} }
console.log(`Updating ${ this.urlParams.id }'s status...`, this.bodyParams.status);
const prl = manager.getOneById(this.urlParams.id); const prl = manager.getOneById(this.urlParams.id);
if (prl) { if (prl) {

@ -15,6 +15,8 @@ class AppServerOrchestrator {
Permissions.createOrUpdate('manage-apps', ['admin']); Permissions.createOrUpdate('manage-apps', ['admin']);
} }
this._inDebug = process.env.NODE_ENV !== 'production';
this._model = new AppsModel(); this._model = new AppsModel();
this._logModel = new AppsLogsModel(); this._logModel = new AppsLogsModel();
this._persistModel = new AppsPersistenceModel(); this._persistModel = new AppsPersistenceModel();
@ -77,6 +79,17 @@ class AppServerOrchestrator {
return this.getManager().areAppsLoaded(); return this.getManager().areAppsLoaded();
} }
isDebugging() {
return this._inDebug;
}
debugLog() {
if (this._inDebug) {
// eslint-disable-next-line
console.log(...arguments);
}
}
load() { load() {
// Don't try to load it again if it has // Don't try to load it again if it has
// already been loaded // already been loaded
@ -108,6 +121,21 @@ settings.addGroup('General', function() {
type: 'boolean', type: 'boolean',
hidden: false, hidden: false,
}); });
this.add('Apps_Framework_Development_Mode', false, {
type: 'boolean',
enableQuery: {
_id: 'Apps_Framework_enabled',
value: true,
},
public: true,
hidden: false,
});
this.add('Apps_Framework_Marketplace_Url', 'https://marketplace.rocket.chat', {
type: 'string',
hidden: true,
});
}); });
}); });

@ -6,7 +6,7 @@ import { Settings } from '../../../models';
import { getRedirectUri } from './getRedirectUri'; import { getRedirectUri } from './getRedirectUri';
export function getWorkspaceAccessToken() { export function getWorkspaceAccessToken(forceNew = false, scope = '', save = true) {
if (!settings.get('Register_Server')) { if (!settings.get('Register_Server')) {
return ''; return '';
} }
@ -19,7 +19,7 @@ export function getWorkspaceAccessToken() {
const expires = Settings.findOneById('Cloud_Workspace_Access_Token_Expires_At'); const expires = Settings.findOneById('Cloud_Workspace_Access_Token_Expires_At');
const now = new Date(); const now = new Date();
if (now < expires.value) { if (now < expires.value && !forceNew) {
return settings.get('Cloud_Workspace_Access_Token'); return settings.get('Cloud_Workspace_Access_Token');
} }
@ -34,6 +34,7 @@ export function getWorkspaceAccessToken() {
query: querystring.stringify({ query: querystring.stringify({
client_id, client_id,
client_secret, client_secret,
scope,
grant_type: 'client_credentials', grant_type: 'client_credentials',
redirect_uri: redirectUri, redirect_uri: redirectUri,
}), }),
@ -42,12 +43,13 @@ export function getWorkspaceAccessToken() {
return ''; return '';
} }
const expiresAt = new Date(); if (save) {
expiresAt.setSeconds(expiresAt.getSeconds() + authTokenResult.data.expires_in); const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + authTokenResult.data.expires_in);
Settings.updateValueById('Cloud_Workspace_Access_Token', authTokenResult.data.access_token);
Settings.updateValueById('Cloud_Workspace_Access_Token_Expires_At', expiresAt);
Settings.updateValueById('Cloud_Workspace_Access_Token', authTokenResult.data.access_token);
Settings.updateValueById('Cloud_Workspace_Access_Token_Expires_At', expiresAt);
}
return authTokenResult.data.access_token; return authTokenResult.data.access_token;
} }

@ -23,11 +23,6 @@
} }
&-search { &-search {
height: 48px;
margin-right: 5px;
margin-left: 5px;
& .rc-icon { & .rc-icon {
width: 0.875rem; width: 0.875rem;
} }

@ -6,7 +6,11 @@
{{> burger}} {{> burger}}
</div> </div>
<span class="rc-header__block">{{_ sectionName}}</span> {{#if rawSectionName}}
<span class="rc-header__block">{{rawSectionName}}</span>
{{else}}
<span class="rc-header__block">{{_ sectionName}}</span>
{{/if}}
{{#if Template.contentBlock}} {{#if Template.contentBlock}}
{{> Template.contentBlock}} {{> Template.contentBlock}}

@ -343,6 +343,8 @@
"Apply_and_refresh_all_clients": "Apply and refresh all clients", "Apply_and_refresh_all_clients": "Apply and refresh all clients",
"Apps": "Apps", "Apps": "Apps",
"Apps_Engine_Version": "Apps Engine Version", "Apps_Engine_Version": "Apps Engine Version",
"Apps_Framework_Development_Mode": "Eneble 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_Framework_enabled": "Enable the App Framework",
"Apps_Settings": "App's Settings", "Apps_Settings": "App's Settings",
"Apps_WhatIsIt": "Apps: What Are They?", "Apps_WhatIsIt": "Apps: What Are They?",
@ -449,6 +451,7 @@
"Broadcasting_client_secret": "Broadcasting Client Secret", "Broadcasting_client_secret": "Broadcasting Client Secret",
"Broadcasting_enabled": "Broadcasting Enabled", "Broadcasting_enabled": "Broadcasting Enabled",
"Broadcasting_media_server_url": "Broadcasting Media Server URL", "Broadcasting_media_server_url": "Broadcasting Media Server URL",
"Browse_Files": "Browse Files",
"Bugsnag_api_key": "Bugsnag API Key", "Bugsnag_api_key": "Bugsnag API Key",
"Build_Environment": "Build Environment", "Build_Environment": "Build Environment",
"bulk-create-c": "Bulk Create Channels", "bulk-create-c": "Bulk Create Channels",
@ -1391,6 +1394,7 @@
"Forward_chat": "Forward chat", "Forward_chat": "Forward chat",
"Forward_to_department": "Forward to department", "Forward_to_department": "Forward to department",
"Forward_to_user": "Forward to user", "Forward_to_user": "Forward to user",
"Free": "Free",
"Frequently_Used": "Frequently Used", "Frequently_Used": "Frequently Used",
"Friday": "Friday", "Friday": "Friday",
"From": "From", "From": "From",
@ -1543,6 +1547,7 @@
"Install_FxOs_follow_instructions": "Please confirm the app installation on your device (press \"Install\" when prompted).", "Install_FxOs_follow_instructions": "Please confirm the app installation on your device (press \"Install\" when prompted).",
"Install_package": "Install package", "Install_package": "Install package",
"Installation": "Installation", "Installation": "Installation",
"Installed": "Installed",
"Installed_at": "Installed at", "Installed_at": "Installed at",
"Invitation_HTML": "Invitation HTML", "Invitation_HTML": "Invitation HTML",
"Instance_Record": "Instance Record", "Instance_Record": "Instance Record",
@ -2292,6 +2297,7 @@
"Public_Channel": "Public Channel", "Public_Channel": "Public Channel",
"Public_Community": "Public Community", "Public_Community": "Public Community",
"Public_Relations": "Public Relations", "Public_Relations": "Public Relations",
"Purchased": "Purchased",
"Push": "Push", "Push": "Push",
"Push_Setting_Requires_Restart_Alert": "Changing this value requires restarting Rocket.Chat.", "Push_Setting_Requires_Restart_Alert": "Changing this value requires restarting Rocket.Chat.",
"Push_apn_cert": "APN Cert", "Push_apn_cert": "APN Cert",
@ -2310,6 +2316,8 @@
"Push_show_message": "Show Message in Notification", "Push_show_message": "Show Message in Notification",
"Push_show_username_room": "Show Channel/Group/Username in Notification", "Push_show_username_room": "Show Channel/Group/Username in Notification",
"Push_test_push": "Test", "Push_test_push": "Test",
"Purchase_for_free": "Purchase for FREE",
"Purchase_for_price": "Purchase for $%s",
"Query": "Query", "Query": "Query",
"Query_description": "Additional conditions for determining which users to send the email to. Unsubscribed users are automatically removed from the query. It must be a valid JSON. Example: \"{\"createdAt\":{\"$gt\":{\"$date\": \"2015-01-01T00:00:00.000Z\"}}}\"", "Query_description": "Additional conditions for determining which users to send the email to. Unsubscribed users are automatically removed from the query. It must be a valid JSON. Example: \"{\"createdAt\":{\"$gt\":{\"$date\": \"2015-01-01T00:00:00.000Z\"}}}\"",
"Queue": "Queue", "Queue": "Queue",
@ -3169,4 +3177,4 @@
"Your_question": "Your question", "Your_question": "Your question",
"Your_server_link": "Your server link", "Your_server_link": "Your server link",
"Your_workspace_is_ready": "Your workspace is ready to use 🎉" "Your_workspace_is_ready": "Your workspace is ready to use 🎉"
} }

Loading…
Cancel
Save