[NEW] Subscription enabled marketplace (#14948)

* Show the subscription apps different from regular purchase prices

* Update app download calls return a Buffer directly

* Add X-Apps-Engine-Version header to marketplace requests

* Add getActiveUserCount method to apps-engine user bridge

* Add distinction  for purchase type on apps list

* Fix the issues with displaying subscriptions

* Remove external federated users from active user count

* Fix usage of federation module

* Change app installation to validate license

* Change the bridges to correctly query the workspace public key

* Change marketplaceUrl to marketplace-beta

* Update Apps and Marketplace styles (temp)

* Update price column on Marketplace

* Update status column on Marketplace

* Fix Marketplace app list update

* Remove log from client orchestrator

* Refactor server orchestrator

* Change rest api for license validation

* Add popover to Marketplace app list

* Add card (subscription) icon

* Update active user count method

* Update appManage template (partial)

* Update appManage template

* Add missing i18n strings

* Add cron routine to update apps info

* Add options parameter to new methods on model Users

* Revert testing settings

* Bump Apps-Engine version
pull/15015/head^2
Douglas Gubert 6 years ago committed by Diego Sampaio
parent d022df2ec5
commit 3fb0cca52f
  1. 51
      app/apps/assets/stylesheets/apps.css
  2. 147
      app/apps/client/admin/appManage.css
  3. 103
      app/apps/client/admin/appManage.html
  4. 539
      app/apps/client/admin/appManage.js
  5. 10
      app/apps/client/admin/apps.html
  6. 68
      app/apps/client/admin/marketplace.css
  7. 102
      app/apps/client/admin/marketplace.html
  8. 511
      app/apps/client/admin/marketplace.js
  9. 2
      app/apps/client/index.js
  10. 1
      app/apps/client/orchestrator.js
  11. 8
      app/apps/server/bridges/internal.js
  12. 6
      app/apps/server/bridges/users.js
  13. 117
      app/apps/server/communication/rest.js
  14. 44
      app/apps/server/cron.js
  15. 2
      app/apps/server/index.js
  16. 24
      app/apps/server/orchestrator.js
  17. 12
      app/models/server/models/Users.js
  18. 3
      app/ui-master/public/icons/card.svg
  19. 483
      package-lock.json
  20. 2
      package.json
  21. 6
      packages/rocketchat-i18n/i18n/en.i18n.json
  22. 3
      private/public/icons.svg
  23. 5
      public/public/icons.html

@ -1,3 +1,4 @@
.rc-apps-section,
.rc-apps-marketplace {
display: flex;
@ -300,6 +301,12 @@
height: 100vh;
margin-top: 20px;
& .rc-form-filters {
margin: 8px 0;
}
& .js-sort {
cursor: pointer;
@ -321,6 +328,50 @@
font-size: 1rem;
}
}
& tbody .rc-table-tr:not(.table-no-click):not(.table-no-pointer):hover {
background-color: #f7f8fa;
}
& .rc-table-info {
margin: 0;
justify-content: center;
& .rc-table-title,
& .rc-table-subtitle {
font-size: 0.875rem;
line-height: 1.25rem;
}
& .rc-apps-categories {
display: flex;
height: 1.25rem;
margin: 0 -0.25rem;
align-items: center;
flex-wrap: wrap;
& .rc-apps-category {
overflow: hidden;
flex: 0 0 auto;
box-sizing: border-box;
margin: 0.125rem 0.25rem;
padding: 0.0625rem 0.25rem;
text-transform: none;
text-overflow: ellipsis;
color: #9da1a8;
border-radius: 9999px;
background-color: #eef0f3;
font-size: 0.625rem;
font-weight: 500;
line-height: 0.875rem;
}
}
}
}
@media (width <= 700px) {

@ -0,0 +1,147 @@
#rocket-chat .content .rc-apps-details {
&__content {
justify-content: flex-start;
}
&__app-name {
flex: 0 0 1.75rem;
margin: 0;
letter-spacing: 0;
text-transform: none;
color: rgb(84, 88, 94);
font-family: inherit;
font-size: 1.375rem;
font-weight: normal;
line-height: 1.75rem;
}
&__app-info {
display: flex;
flex: 0 0 1.25rem;
flex-wrap: nowrap;
> span::after {
display: inline-block;
width: 1px;
height: 12px;
margin: 0 8px;
content: '';
background: rgb(203, 206, 209);
}
> span:last-child::after {
display: none;
content: none;
}
}
&__app-author {
letter-spacing: -0.2px;
color: rgb(158, 162, 168);
font-family: inherit;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__app-version {
letter-spacing: -0.2px;
color: rgb(158, 162, 168);
font-family: inherit;
font-size: 14px;
font-weight: normal;
line-height: 20px;
}
&__app-status {
display: flex;
flex: 1;
margin-top: 8px;
align-items: center;
}
&__app-install-status {
display: flex;
height: 40px;
letter-spacing: 0;
color: rgb(158, 162, 168);
font-family: inherit;
font-size: 14px;
font-weight: 500;
align-items: center;
flex-wrap: nowrap;
& > .rc-icon {
color: var(--rc-color-button-primary);
}
}
&__app-price {
letter-spacing: -0.2px;
color: rgb(157, 161, 168);
font-family: inherit;
font-size: 14px;
font-weight: normal;
line-height: 20px;
&::before {
display: inline-block;
width: 1px;
height: 12px;
margin: 0 16px;
content: '';
background: rgb(203, 206, 209);
}
}
&__app-button-wrapper {
flex: 1;
}
& .rc-button.loading {
padding: 0 1.5rem;
opacity: 0.6;
&::before {
display: none;
}
& > .rc-icon {
animation: spin 1s linear infinite;
}
}
&__app-menu-trigger {
padding: 0;
&::before {
display: inline-block;
flex: 1;
content: '';
}
}
}

@ -16,10 +16,10 @@
{{/header}}
<div class="content">
{{#requiresPermission 'manage-apps'}}
{{#if hasError}}
{{#if error}}
<div class="apps-error error-color">
<i class="icon-attention"></i>
<p>{{theError}}</p>
<p>{{error}}</p>
</div>
{{else if isReady}}
<div class="rc-apps-details">
@ -30,35 +30,60 @@
<div class="rc-apps-details__photo" style="background-image:url({{iconFileContent}})"></div>
{{/if}}
<div class="rc-apps-details__content">
<div class="rc-apps-details__row">
<h1>{{name}}</h1>
<h2 class="rc-apps-details__app-name">{{name}}</h2>
<div class="rc-apps-details__app-info">
{{#if author.name}}
<span class="rc-apps-details__app-author">by {{author.name}}</span>
{{/if}}
<span class="rc-apps-details__app-version">Version {{version}}</span>
</div>
{{#if author.name}}
<div class="rc-apps-details__version">
<strong class="rc-apps-details__author">by {{author.name}}</strong> | Version {{version}}
</div>
{{/if}}
<div class="rc-apps-details__row">
<div class="rc-apps-details__app-status">
{{#if isInstalled}}
{{#if newVersion}}
<button class="rc-button rc-button--primary js-install">{{> icon icon="circled-arrow-down"}} {{_ "Update_to_version" version=newVersion }}</button>
{{/if}}
<button class="rc-button rc-button--nude js-uninstall">{{> icon icon="trash"}} {{_ "Delete" }}</button>
{{#if isEnabled}}
<button class="rc-button rc-button--nude js-deactivate">{{> icon icon="ban"}} {{_ "Deactivate" }}</button>
{{else}}
<button class="rc-button rc-button--nude js-activate">{{> icon icon="check"}} {{_ "Activate" }}</button>
{{/if}}
<button class="rc-button rc-button--nude js-view-logs">{{> icon icon="list-alt"}} {{_ "View_Logs" }}</button>
{{else}}
{{#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>
<span class="rc-apps-details__app-button-wrapper">
{{#if canUpdate}}
<button class="rc-button rc-button--primary js-install">
{{> 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}}
<button class="rc-button rc-button--primary js-purchase">{{> icon icon="circled-arrow-down"}} {{displayPrice}}</button>
<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"}}
</button>
{{/if}}
{{#if priceDisplay}}
<span class="rc-apps-details__app-price">
{{priceDisplay}}
</span>
{{/if}}
{{/if}}
</div>
@ -390,30 +415,6 @@
</div>
{{/if}}
</div>
<!-- <div class="horizontal">
{{#if $eq editor 'color'}}
<div class="flex-grow-1">
<input class="input-monitor colorpicker-input" type="text" name="{{id}}" value="{{value}}" autocomplete="off"/>
<span class="colorpicker-swatch border-component-color" style="background-color: {{value}}"></span>
</div>
{{/if}}
{{#if $eq editor 'expression'}}
<div class="flex-grow-1">
<input class="input-monitor" type="" name="{{id}}" value="{{value}}"/>
</div>
{{/if}}
<div class="color-editor">
<div class="select-arrow">
<i class="icon-down-open secondary-font-color"></i>
</div>
<select name="color-editor">
{{#each allowedTypes}}
<option value="{{.}}" selected="{{$eq ../editor .}}">{{_ .}}</option>
{{/each}}
</select>
</div>
</div>
<div class="settings-description">Variable name: {{getColorVariable id}}</div> -->
{{ else if $eq type 'language'}}
<div class="rc-input">

@ -9,16 +9,19 @@ import s from 'underscore.string';
import toastr from 'toastr';
import semver from 'semver';
import { isEmail, APIClient } from '../../../utils';
import { settings } from '../../../settings';
import { isEmail, t, APIClient } from '../../../utils';
import { Markdown } from '../../../markdown/client';
import { modal } from '../../../ui-utils';
import { AppEvents } from '../communication';
import { Utilities } from '../../lib/misc/Utilities';
import { Apps } from '../orchestrator';
import { SideNav } from '../../../ui-utils/client';
import { SideNav, popover } from '../../../ui-utils/client';
function getApps(instance) {
import './appManage.html';
import './appManage.css';
const getApp = (instance) => {
const id = instance.id.get();
const appInfo = { remote: undefined, local: undefined };
@ -26,7 +29,7 @@ function getApps(instance) {
.catch((e) => {
console.log(e);
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
return Promise.resolve({ app: undefined });
return { app: undefined };
})
.then((remote) => {
if (!remote.app || !remote.app.bundledIn || remote.app.bundledIn.length === 0) {
@ -59,11 +62,10 @@ function getApps(instance) {
.then((apis) => instance.apis.set(apis))
.catch((e) => {
if (appInfo.remote || appInfo.local) {
return Promise.resolve(true);
return true;
}
instance.hasError.set(true);
instance.theError.set(e.message);
instance.error.set(e.message);
}).then((goOn) => {
if (typeof goOn !== 'undefined' && !goOn) {
return;
@ -82,6 +84,8 @@ function getApps(instance) {
appInfo.local.price = appInfo.remote.price;
appInfo.local.displayPrice = appInfo.remote.displayPrice;
appInfo.local.bundledIn = appInfo.remote.bundledIn;
appInfo.local.purchaseType = appInfo.remote.purchaseType;
appInfo.local.subscriptionInfo = appInfo.remote.subscriptionInfo;
if (semver.gt(appInfo.remote.version, appInfo.local.version) && (appInfo.remote.isPurchased || appInfo.remote.price <= 0)) {
appInfo.local.newVersion = appInfo.remote.version;
@ -107,7 +111,7 @@ function getApps(instance) {
}
}
return Promise.resolve(false);
return false;
}).then((updateInfo) => {
if (!updateInfo) {
return;
@ -121,43 +125,146 @@ function getApps(instance) {
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);
};
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 handleAPIError = (e) => {
console.error(e);
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
};
const triggerButtonLoadingState = (button) => {
const icon = button.querySelector('.rc-icon use');
const iconHref = icon.getAttribute('href');
button.classList.add('loading');
button.disabled = true;
icon.setAttribute('href', '#icon-loading');
return () => {
button.classList.remove('loading');
button.disabled = false;
icon.setAttribute('href', iconHref);
};
};
const promptSubscription = async (app, instance) => {
const { latest, purchaseType = 'buy' } = app;
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 getApp(instance);
} catch (e) {
handleAPIError(e, instance);
}
});
};
// play animation
// TODO this icon and animation are not working
$(e.currentTarget).find('.rc-icon').addClass('play');
}
const viewLogs = ({ id }) => {
FlowRouter.go(`/admin/apps/${ id }/logs`, {}, { version: FlowRouter.getQueryParam('version') });
};
const setAppStatus = async (app, status, instance) => {
try {
const result = await APIClient.post(`apps/${ app.id }/status`, { status });
app.status = result.status;
instance.app.set(app);
} catch (e) {
handleAPIError(e, instance);
}
};
const activateApp = (app, instance) => {
setAppStatus(app, 'manually_enabled', instance);
};
const promptAppDeactivation = (app, instance) => {
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(app, 'manually_disabled', instance);
});
};
const uninstallApp = async ({ id }, instance) => {
try {
await APIClient.delete(`apps/${ id }`);
} catch (e) {
handleAPIError(e, instance);
}
try {
await getApp(instance);
} catch (e) {
handleAPIError(e, instance);
}
};
const promptAppUninstall = (app, instance) => {
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(app, instance);
});
};
Template.appManage.onCreated(function() {
const instance = this;
this.id = new ReactiveVar(FlowRouter.getParam('appId'));
this.ready = new ReactiveVar(false);
this.hasError = new ReactiveVar(false);
this.theError = new ReactiveVar('');
this.processingEnabled = new ReactiveVar(false);
this.error = new ReactiveVar('');
this.app = new ReactiveVar({});
this.appsList = new ReactiveVar([]);
this.settings = new ReactiveVar({});
@ -165,14 +272,29 @@ Template.appManage.onCreated(function() {
this.loading = new ReactiveVar(false);
const id = this.id.get();
getApps(instance);
getApp(instance);
this.__ = (key, options, lang_tag) => {
const appKey = Utilities.getI18nKeyForApp(key, id);
return TAPi18next.exists(`project:${ appKey }`) ? TAPi18n.__(appKey, options, lang_tag) : TAPi18n.__(key, options, lang_tag);
};
function _morphSettings(settings) {
this.onStatusChanged = ({ appId, status }) => {
if (appId !== id) {
return;
}
const app = instance.app.get();
app.status = status;
instance.app.set(app);
};
this.onSettingUpdated = async ({ appId }) => {
if (appId !== id) {
return;
}
const { settings } = await APIClient.get(`apps/${ id }/settings`);
Object.keys(settings).forEach((k) => {
settings[k].i18nPlaceholder = settings[k].i18nPlaceholder || ' ';
settings[k].value = settings[k].value !== undefined && settings[k].value !== null ? settings[k].value : settings[k].packageValue;
@ -181,37 +303,80 @@ Template.appManage.onCreated(function() {
});
instance.settings.set(settings);
}
};
instance.onStatusChanged = function _onStatusChanged({ appId, status }) {
if (appId !== id) {
this.onAppAdded = async (appId) => {
if (appId !== this.id.get()) {
return;
}
const app = instance.app.get();
app.status = status;
instance.app.set(app);
try {
await getApp(instance);
} catch (e) {
handleAPIError(e, this);
}
};
instance.onSettingUpdated = function _onSettingUpdated({ appId }) {
if (appId !== id) {
this.onAppRemoved = async (appId) => {
if (appId !== this.id.get()) {
return;
}
APIClient.get(`apps/${ id }/settings`).then((result) => {
_morphSettings(result.settings);
});
try {
await getApp(instance);
} catch (e) {
handleAPIError(e, this);
}
};
Apps.getWsListener().registerListener(AppEvents.APP_ADDED, this.onAppAdded);
Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, this.onAppRemoved);
});
Template.apps.onDestroyed(function() {
const instance = this;
Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged);
Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated);
Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, this.onStatusChanged);
Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, this.onSettingUpdated);
Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, this.onAppAdded);
Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, this.onAppRemoved);
});
Template.appManage.helpers({
isInstalled() {
const app = Template.instance().app.get();
return app.installed;
},
canUpdate() {
const app = Template.instance().app.get();
return app.installed && app.newVersion;
},
isFromMarketplace() {
const app = Template.instance().app.get();
return app.subscriptionInfo;
},
canTrial() {
const app = Template.instance().app.get();
return app.purchaseType === 'subscription' && app.subscriptionInfo && !app.subscriptionInfo.status;
},
canBuy() {
const app = Template.instance().app.get();
return app.price > 0;
},
priceDisplay() {
const app = Template.instance().app.get();
if (app.purchaseType === 'subscription') {
if (!app.pricingPlans || !Array.isArray(app.pricingPlans) || app.pricingPlans.length === 0) {
return;
}
return formatPricingPlan(app.pricingPlans[0]);
}
if (app.price > 0) {
return formatPrice(app.price);
}
return 'Free';
},
isEmail,
_(key, ...args) {
const options = args.pop().hash;
@ -237,22 +402,10 @@ Template.appManage.helpers({
});
return result;
},
appLanguage(key) {
const setting = settings.get('Language');
return setting && setting.split('-').shift().toLowerCase() === key;
},
selectedOption(_id, val) {
const settings = Template.instance().settings.get();
return settings[_id].value === val;
},
getColorVariable(color) {
return color.replace(/theme-color-/, '@');
},
dirty() {
const t = Template.instance();
const settings = t.settings.get();
return Object.keys(settings).some((k) => settings[k].hasChanged);
},
disabled() {
const t = Template.instance();
const settings = t.settings.get();
@ -265,46 +418,13 @@ Template.appManage.helpers({
return false;
},
hasError() {
if (Template.instance().hasError) {
return Template.instance().hasError.get();
}
return false;
},
theError() {
if (Template.instance().theError) {
return Template.instance().theError.get();
error() {
if (Template.instance().error) {
return Template.instance().error.get();
}
return '';
},
isProcessingEnabled() {
if (Template.instance().processingEnabled) {
return Template.instance().processingEnabled.get();
}
return false;
},
isEnabled() {
if (!Template.instance().app) {
return false;
}
const info = Template.instance().app.get();
return info.status === 'auto_enabled' || info.status === 'manually_enabled';
},
isInstalled() {
const instance = Template.instance();
return instance.app.get().installed === true;
},
hasPurchased() {
const instance = Template.instance();
return instance.app.get().isPurchased === true;
},
app() {
return Template.instance().app.get();
},
@ -346,94 +466,7 @@ Template.appManage.helpers({
},
});
async function setActivate(actiavate, e, t) {
t.processingEnabled.set(true);
const el = $(e.currentTarget);
el.prop('disabled', true);
const status = actiavate ? 'manually_enabled' : 'manually_disabled';
try {
const result = await APIClient.post(`apps/${ t.id.get() }/status`, { status });
const info = t.app.get();
info.status = result.status;
t.app.set(info);
} catch (e) {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
}
t.processingEnabled.set(false);
el.prop('disabled', false);
}
Template.appManage.events({
'click .expand': (e) => {
$(e.currentTarget).closest('.section').removeClass('section-collapsed');
$(e.currentTarget).closest('button').removeClass('expand').addClass('collapse').find('span').text(TAPi18n.__('Collapse'));
$('.CodeMirror').each((index, codeMirror) => codeMirror.CodeMirror.refresh());
},
'click .collapse': (e) => {
$(e.currentTarget).closest('.section').addClass('section-collapsed');
$(e.currentTarget).closest('button').addClass('expand').removeClass('collapse').find('span').text(TAPi18n.__('Expand'));
},
'click .js-cancel'() {
FlowRouter.go('/admin/apps');
},
'click .js-activate'(e, t) {
setActivate(true, e, t);
},
'click .js-deactivate'(e, t) {
setActivate(false, e, t);
},
'click .js-uninstall': async (e, t) => {
t.ready.set(false);
try {
await APIClient.delete(`apps/${ t.id.get() }`);
FlowRouter.go('/admin/apps');
} catch (err) {
console.warn('Error:', err);
} finally {
t.ready.set(true);
}
},
'click .js-install': async (e, t) => {
installAppFromEvent(e, t);
},
'click .js-purchase': (e, t) => {
const rl = t.app.get();
APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.id }`)
.then((data) => {
data.successCallback = async () => {
installAppFromEvent(e, t);
};
modal.open({
allowOutsideClick: false,
data,
template: 'iframeModal',
});
})
.catch((e) => {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
});
},
'click .js-update': (e, t) => {
FlowRouter.go(`/admin/app/install?isUpdatingId=${ t.id.get() }`);
},
'click .js-view-logs': (e, t) => {
FlowRouter.go(`/admin/apps/${ t.id.get() }/logs`, {}, { version: FlowRouter.getQueryParam('version') });
},
'click .js-cancel-editing': async (e, t) => {
t.onSettingUpdated({ appId: t.id.get() });
},
@ -453,7 +486,6 @@ Template.appManage.events({
if (setting.hasChanged) {
toSave.push(setting);
}
// return !!setting.hasChanged;
});
if (toSave.length === 0) {
@ -477,6 +509,129 @@ Template.appManage.events({
}
},
'click .js-cancel'() {
FlowRouter.go('/admin/apps');
},
'click .js-menu'(e, instance) {
e.stopPropagation();
const { currentTarget } = e;
const app = instance.app.get();
const isActive = app && ['auto_enabled', 'manually_enabled'].includes(app.status);
popover.open({
currentTarget,
instance,
columns: [{
groups: [
{
items: [
...this.purchaseType === 'subscription' ? [{
icon: 'card',
name: t('Subscription'),
action: () => promptSubscription(this, instance),
}] : [],
{
icon: 'list-alt',
name: t('View_Logs'),
action: () => viewLogs(app, instance),
},
],
},
{
items: [
isActive
? {
icon: 'ban',
name: t('Deactivate'),
modifier: 'alert',
action: () => promptAppDeactivation(app, instance),
}
: {
icon: 'check',
name: t('Activate'),
action: () => activateApp(app, instance),
},
{
icon: 'trash',
name: t('Uninstall'),
modifier: 'alert',
action: () => promptAppUninstall(app, instance),
},
],
},
],
}],
});
},
async 'click .js-install'(e, instance) {
e.stopPropagation();
const { currentTarget: button } = e;
const stopLoading = triggerButtonLoadingState(button);
const { id, version } = instance.app.get();
try {
await APIClient.post('apps/', {
appId: id,
marketplace: true,
version,
});
} catch (e) {
handleAPIError(e, instance);
}
try {
await getApp(instance);
} catch (e) {
handleAPIError(e, instance);
} finally {
stopLoading();
}
},
async 'click .js-purchase'(e, instance) {
const { id, purchaseType = 'buy', version } = instance.app.get();
const { currentTarget: button } = e;
const stopLoading = triggerButtonLoadingState(button);
let data = null;
try {
data = await APIClient.get(`apps?buildExternalUrl=true&appId=${ id }&purchaseType=${ purchaseType }`);
} catch (e) {
handleAPIError(e, instance);
stopLoading();
return;
}
modal.open({
allowOutsideClick: false,
data,
template: 'iframeModal',
}, async () => {
try {
await APIClient.post('apps/', {
appId: id,
marketplace: true,
version,
});
} catch (e) {
handleAPIError(e, instance);
}
try {
await getApp(instance);
} catch (e) {
handleAPIError(e, instance);
} finally {
stopLoading();
}
}, stopLoading);
},
'change input[type="checkbox"]': (e, t) => {
const labelFor = $(e.currentTarget).attr('name');
const isChecked = $(e.currentTarget).prop('checked');

@ -1,5 +1,5 @@
<template name="apps">
<section class="rc-apps-marketplace">
<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">
@ -13,7 +13,7 @@
</div>
{{/header}}
<div class="rc-table-content">
<form class="js-search-form" role="form">
<form class="rc-form-filters js-search-form" role="form">
<div class="rc-input">
<div class="rc-input__icon">
{{#if isLoading}}
@ -79,8 +79,10 @@
{{latest.description}}
</span>
{{/if}}
<span class="rc-table-subtitle">
{{formatCategories latest.categories}}
<span class="rc-table-subtitle rc-apps-categories">
{{#each category in latest.categories}}
<span class="rc-apps-category">{{category}}</span>
{{/each}}
</span>
</div>
</div>

@ -0,0 +1,68 @@
.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,5 +1,5 @@
<template name="marketplace">
<section class="rc-apps-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">
@ -8,7 +8,7 @@
{{/unless}}
{{/header}}
<div class="rc-table-content">
<form class="js-search-form" role="form">
<form class="rc-form-filters js-search-form" role="form">
<div class="rc-input">
<div class="rc-input__icon">
{{#if isLoading}}
@ -35,15 +35,19 @@
<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-table-td--small rc-apps-marketplace-price">
<th class="rc-table-td--medium">
<div class="table-fake-th">{{_ "Price"}}</div>
</th>
<th class="rc-table-td--small">
<div class="table-fake-th">{{_ "Status"}}</div>
</th>
</tr>
</thead>
<tbody>
{{#each apps}}
<tr class="rc-table-tr manage" data-name="{{latest.name}}">
<tr class="rc-table-tr js-open" data-name="{{latest.name}}">
<td>
<div class="rc-table-wrapper">
{{#if latest.iconFileData}}
@ -76,50 +80,74 @@
{{latest.description}}
</span>
{{/if}}
<span class="rc-table-subtitle">
{{formatCategories latest.categories}}
<span class="rc-table-subtitle rc-apps-categories">
{{#each category in latest.categories}}
<span class="rc-apps-category">{{category}}</span>
{{/each}}
</span>
</div>
</div>
</td>
<td class="rc-apps-marketplace-price">
{{#if $eq latest._installed true}}
<button class="apps-installer" data-app="{{appId}}">
{{_ "Installed"}}
{{> icon icon="menu" block="rc-icon--default-size"}}
</button>
{{/if}}
{{#if renderDownloadButton latest}}
<div class="rc-apps-marketplace__wrap-actions">
{{#if $eq isPurchased true}}
<button class="js-install apps-installer" data-app="{{appId}}">
{{_ "Purchased"}}
{{> icon block="installer rc-icon--default-size" icon="download"}}
<td>
<div class="rc-table-wrapper">
<div class="rc-table-info">
<div class="rc-table-title">
{{purchaseTypeDisplay .}}
</div>
<div class="rc-table-subtitle">
{{priceDisplay .}}
</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"}}
</button>
{{else if isOnTrialPeriod .}}
<span class="rc-apps-marketplace__app-status">
{{> icon icon="checkmark-circled" block="rc-icon--default-size"}}
{{_ "Trial period"}}
</span>
{{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"}}
<span class="rc-apps-marketplace__app-status">
{{> icon icon="checkmark-circled" block="rc-icon--default-size"}}
{{_ "Installed"}}
</span>
{{/if}}
<button class="rc-apps-marketplace__app-menu-trigger js-menu" data-app="{{appId}}">
{{> 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}}
<span class="loading">
Downloading
{{> icon block="rc-icon--default-size rc-icon" icon="loading"}}
</span>
</div>
{{/if}}
{{/if}}
</div>
</td>
</tr>
{{/each}}
{{#if isLoading}}
<tr>
<td colspan="3" style="position: relative;">{{> loading}}</td>
<tr class="table-no-click table-no-pointer">
<td colspan="4" style="position: relative">{{> loading}}</td>
</tr>
{{/if}}
</tbody>

@ -4,13 +4,17 @@ import { ReactiveVar } from 'meteor/reactive-var';
import { FlowRouter } from 'meteor/kadira:flow-router';
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 { AppEvents } from '../communication';
import { Apps } from '../orchestrator';
import { SideNav } from '../../../ui-utils/client';
import { SideNav, popover } from '../../../ui-utils/client';
import './marketplace.html';
import './marketplace.css';
const ENABLED_STATUS = ['auto_enabled', 'manually_enabled'];
const enabled = ({ status }) => ENABLED_STATUS.includes(status);
@ -23,21 +27,25 @@ const sortByColumn = (array, column, inverted) =>
return 1;
});
const tagAlreadyInstalledApps = (installedApps, apps) => {
const installedIds = installedApps.map((app) => app.latest.id);
const tagged = apps.map((app) =>
({
price: app.price,
isPurchased: app.isPurchased,
latest: {
...app.latest,
_installed: installedIds.includes(app.latest.id),
},
})
);
return tagged;
const 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) => {
@ -45,11 +53,10 @@ const getApps = async (instance) => {
try {
const data = await APIClient.get('apps?marketplace=true');
const tagged = tagAlreadyInstalledApps(instance.installedApps.get(), data);
instance.apps.set(tagged);
instance.apps.set(data);
} catch (e) {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
handleAPIError(e, instance);
}
instance.isLoading.set(false);
@ -62,27 +69,171 @@ const getInstalledApps = async (instance) => {
const apps = data.apps.map((app) => ({ latest: app }));
instance.installedApps.set(apps);
} catch (e) {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
handleAPIError(e, instance);
}
};
const getCloudLoggedIn = async (instance) => {
Meteor.call('cloud:checkUserLoggedIn', (error, result) => {
if (error) {
console.warn(error);
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');
}
});
return false;
};
const triggerButtonLoadingState = (button) => {
const icon = button.querySelector('.rc-icon use');
const iconHref = icon.getAttribute('href');
button.classList.add('loading');
button.disabled = true;
icon.setAttribute('href', '#icon-loading');
return () => {
button.classList.remove('loading');
button.disabled = false;
icon.setAttribute('href', iconHref);
};
};
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);
});
};
instance.cloudLoggedIn.set(result);
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() {
const instance = this;
this.ready = new ReactiveVar(false);
this.apps = new ReactiveVar([]);
this.installedApps = new ReactiveVar([]);
this.categories = new ReactiveVar([]);
this.searchText = new ReactiveVar('');
this.searchSortBy = new ReactiveVar('name');
this.sortDirection = new ReactiveVar('asc');
@ -92,55 +243,33 @@ Template.marketplace.onCreated(function() {
this.isLoading = new ReactiveVar(true);
this.cloudLoggedIn = new ReactiveVar(false);
getInstalledApps(instance);
getApps(instance);
try {
APIClient.get('apps?categories=true').then((data) => instance.categories.set(data));
} catch (e) {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
}
instance.onAppAdded = function _appOnAppAdded() {
// ToDo: fix this formatting data to add an app to installedApps array without to fetch all
// fetch(`${ HOST }/v1/apps/${ appId }`).then((result) => {
// const installedApps = instance.installedApps.get();
// installedApps.push({
// latest: result.app,
// });
// instance.installedApps.set(installedApps);
// });
getInstalledApps(this);
getApps(this);
getCloudLoggedIn(this);
this.onAppAdded = async (appId) => {
const installedApps = this.installedApps.get().filter((installedApp) => installedApp.appId !== appId);
try {
const { app } = await APIClient.get(`apps/${ appId }`);
installedApps.push({ latest: app });
this.installedApps.set(installedApps);
} catch (e) {
handleAPIError(e, this);
}
};
getCloudLoggedIn(instance);
instance.onAppRemoved = function _appOnAppRemoved(appId) {
const apps = instance.apps.get();
let index = -1;
apps.find((item, i) => {
if (item.id === appId) {
index = i;
return true;
}
return false;
});
apps.splice(index, 1);
instance.apps.set(apps);
this.onAppRemoved = (appId) => {
const apps = this.apps.get().filter(({ id }) => id !== appId);
this.apps.set(apps);
};
Apps.getWsListener().registerListener(AppEvents.APP_ADDED, instance.onAppAdded);
Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, instance.onAppAdded);
Apps.getWsListener().registerListener(AppEvents.APP_ADDED, this.onAppAdded);
Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, this.onAppRemoved);
});
Template.marketplace.onDestroyed(function() {
const instance = this;
Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, instance.onAppAdded);
Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, instance.onAppAdded);
Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, this.onAppAdded);
Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, this.onAppRemoved);
});
Template.marketplace.helpers({
@ -156,10 +285,8 @@ Template.marketplace.helpers({
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();
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;
@ -220,115 +347,181 @@ Template.marketplace.helpers({
sortDirection.set('asc');
};
},
renderDownloadButton(latest) {
return latest._installed === false;
purchaseTypeDisplay(app) {
if (app.purchaseType === 'subscription') {
return t('Subscription');
}
if (app.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) {
return '-';
}
return formatPricingPlan(app.pricingPlans[0]);
}
if (app.price > 0) {
return formatPrice(app.price);
}
return '-';
},
isInstalled(app) {
const { installedApps } = Template.instance();
const installedApp = installedApps.get().find(({ latest: { id } }) => id === app.latest.id);
return !!installedApp;
},
formatPrice(price) {
return `$${ Number.parseFloat(price).toFixed(2) }`;
isOnTrialPeriod(app) {
return app.subscriptionInfo.status === 'trialing';
},
formatCategories(categories = []) {
return categories.join(', ');
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;
},
});
Template.marketplace.events({
'click .manage'() {
const rl = this;
if (rl && rl.latest && rl.latest.id) {
FlowRouter.go(`/admin/apps/${ rl.latest.id }?version=${ rl.latest.version }`);
}
},
'click [data-button="install"]'() {
FlowRouter.go('/admin/app/install');
},
'click [data-button="login"]'() {
FlowRouter.go('/admin/cloud');
},
'click .js-install'(e, template) {
'click .js-open'(e) {
e.stopPropagation();
const { latest: { id, version } } = this;
FlowRouter.go(`/admin/apps/${ id }?version=${ version }`);
},
async 'click .js-install'(e, instance) {
e.stopPropagation();
const elm = e.currentTarget.parentElement;
elm.classList.add('loading');
if (!isLoggedInCloud(instance)) {
return;
}
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');
const { currentTarget: button } = e;
const stopLoading = triggerButtonLoadingState(button);
const { latest } = this;
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);
} finally {
stopLoading();
}
},
'click .js-purchase'(e, template) {
async 'click .js-purchase'(e, instance) {
e.stopPropagation();
const rl = this;
if (!template.cloudLoggedIn.get()) {
modal.open({
title: t('Apps_Marketplace_Login_Required_Title'),
text: t('Apps_Marketplace_Login_Required_Description'),
type: 'info',
showCancelButton: true,
confirmButtonColor: '#DD6B55',
confirmButtonText: t('Login'),
cancelButtonText: t('Cancel'),
closeOnConfirm: true,
html: false,
}, function(confirmed) {
if (confirmed) {
FlowRouter.go('/admin/cloud');
}
});
if (!isLoggedInCloud(instance)) {
return;
}
const { latest, purchaseType = 'buy' } = this;
const { currentTarget: button } = e;
const stopLoading = triggerButtonLoadingState(button);
let data = null;
try {
data = await APIClient.get(`apps?buildExternalUrl=true&appId=${ latest.id }&purchaseType=${ purchaseType }`);
} catch (e) {
handleAPIError(e, instance);
stopLoading();
return;
}
// play animation
const elm = e.currentTarget.parentElement;
APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.latest.id }`)
.then((data) => {
modal.open({
allowOutsideClick: false,
data,
template: 'iframeModal',
}, () => {
elm.classList.add('loading');
APIClient.post('apps/', {
appId: this.latest.id,
marketplace: true,
version: this.latest.version,
})
.then(async () => {
await Promise.all([
getInstalledApps(template),
getApps(template),
]);
elm.classList.remove('loading');
})
.catch((e) => {
toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message);
elm.classList.remove('loading');
});
modal.open({
allowOutsideClick: false,
data,
template: 'iframeModal',
}, async () => {
try {
await APIClient.post('apps/', {
appId: latest.id,
marketplace: true,
version: latest.version,
});
})
.catch((e) => {
const errMsg = (e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message;
toastr.error(errMsg);
if (errMsg === 'Unauthorized') {
getCloudLoggedIn(template);
}
});
await Promise.all([
getInstalledApps(instance),
getApps(instance),
]);
} catch (e) {
handleAPIError(e, instance);
} finally {
stopLoading();
}
}, stopLoading);
},
'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);

@ -1,6 +1,5 @@
import './admin/modalTemplates/iframeModal.html';
import './admin/modalTemplates/iframeModal';
import './admin/marketplace.html';
import './admin/marketplace';
import './admin/apps.html';
import './admin/apps';
@ -8,7 +7,6 @@ import './admin/appInstall.html';
import './admin/appInstall';
import './admin/appLogs.html';
import './admin/appLogs';
import './admin/appManage.html';
import './admin/appManage';
import './admin/appWhatIsIt.html';
import './admin/appWhatIsIt';

@ -37,7 +37,6 @@ class AppClientOrchestrator {
}
load(isEnabled) {
console.log('Loading:', isEnabled);
this._isEnabled = isEnabled;
// It was already loaded, so let's load it again

@ -1,4 +1,4 @@
import { Subscriptions } from '../../../models';
import { Subscriptions, Settings } from '../../../models';
export class AppInternalBridge {
constructor(orch) {
@ -18,4 +18,10 @@ export class AppInternalBridge {
return records.map((s) => s.u.username);
}
getWorkspacePublicKey() {
const publicKeySetting = Settings.findById('Cloud_Workspace_PublicKey').fetch()[0];
return this.orch.getConverters().get('settings').convertToApp(publicKeySetting);
}
}

@ -1,3 +1,5 @@
import { Users } from '../../../models/server';
export class AppUserBridge {
constructor(orch) {
this.orch = orch;
@ -14,4 +16,8 @@ export class AppUserBridge {
return this.orch.getConverters().get('users').convertByUsername(username);
}
async getActiveUserCount() {
return Users.findActive().count() - Users.findActiveRemote().count();
}
}

@ -7,6 +7,12 @@ import { getWorkspaceAccessToken, getUserCloudAccessToken } from '../../../cloud
import { settings } from '../../../settings';
import { Info } from '../../../utils';
const getDefaultHeaders = () => ({
'X-Apps-Engine-Version': Info.marketplaceApiVersion,
});
const purchaseTypes = new Set(['buy', 'subscription']);
export class AppsRestApi {
constructor(orch, manager) {
this._orch = orch;
@ -57,7 +63,7 @@ export class AppsRestApi {
// Gets the Apps from the marketplace
if (this.queryParams.marketplace) {
const headers = {};
const headers = getDefaultHeaders();
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
@ -75,7 +81,7 @@ export class AppsRestApi {
}
if (this.queryParams.categories) {
const headers = {};
const headers = getDefaultHeaders();
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
@ -92,15 +98,23 @@ export class AppsRestApi {
return API.v1.success(result.data);
}
if (this.queryParams.buildBuyUrl && this.queryParams.appId) {
if (this.queryParams.buildExternalUrl && this.queryParams.appId) {
const workspaceId = settings.get('Cloud_Workspace_Id');
if (!this.queryParams.purchaseType || !purchaseTypes.has(this.queryParams.purchaseType)) {
return API.v1.failure({ error: 'Invalid purchase type' });
}
const token = getUserCloudAccessToken(this.getLoggedInUser()._id, true, 'marketplace:purchase', false);
if (!token) {
return API.v1.failure({ error: 'Unauthorized' });
}
return API.v1.success({ url: `${ baseUrl }/apps/${ this.queryParams.appId }/buy?workspaceId=${ workspaceId }&token=${ token }` });
return API.v1.success({
url: `${ baseUrl }/apps/${ this.queryParams.appId }/${
this.queryParams.purchaseType === 'buy' ? this.queryParams.purchaseType : 'subscribe'
}?workspaceId=${ workspaceId }&token=${ token }`,
});
}
const apps = manager.get().map((prl) => {
@ -115,39 +129,66 @@ export class AppsRestApi {
},
post() {
let buff;
let marketplaceInfo;
if (this.bodyParams.url) {
if (settings.get('Apps_Framework_Development_Mode') !== true) {
return API.v1.failure({ error: 'Installation from url is disabled.' });
}
const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: 'binary' } });
const result = HTTP.call('GET', this.bodyParams.url, { npmRequestOptions: { encoding: null } });
if (result.statusCode !== 200 || !result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') {
return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' });
}
buff = Buffer.from(result.content, 'binary');
buff = result.content;
} else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) {
const baseUrl = orchestrator.getMarketplaceUrl();
const headers = {};
const token = getWorkspaceAccessToken(true, 'marketplace:download', false);
const headers = getDefaultHeaders();
const result = HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }?token=${ token }`, {
headers,
npmRequestOptions: { encoding: 'binary' },
const downloadPromise = new Promise((resolve, reject) => {
const token = getWorkspaceAccessToken(true, 'marketplace:download', false);
HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }/download/${ this.bodyParams.version }?token=${ token }`, {
headers,
npmRequestOptions: { encoding: null },
}, (error, result) => {
if (error) { reject(error); }
resolve(result);
});
});
if (result.statusCode !== 200) {
return API.v1.failure();
}
const marketplacePromise = new Promise((resolve, reject) => {
const token = getWorkspaceAccessToken();
if (!result.headers['content-type'] || result.headers['content-type'] !== 'application/zip') {
return API.v1.failure({ error: 'Invalid url. It doesn\'t exist or is not "application/zip".' });
}
HTTP.get(`${ baseUrl }/v1/apps/${ this.bodyParams.appId }?appVersion=${ this.bodyParams.version }`, {
headers: {
Authorization: `Bearer ${ token }`,
...headers,
},
}, (error, result) => {
if (error) { reject(error); }
buff = Buffer.from(result.content, 'binary');
resolve(result);
});
});
try {
const [downloadResult, marketplaceResult] = Promise.await(Promise.all([downloadPromise, marketplacePromise]));
if (!downloadResult.headers['content-type'] || downloadResult.headers['content-type'] !== 'application/zip') {
throw new Error('Invalid url. It doesn\'t exist or is not "application/zip".');
}
buff = downloadResult.content;
marketplaceInfo = marketplaceResult.data[0];
} catch (err) {
return API.v1.failure(err.message);
}
} else {
if (settings.get('Apps_Framework_Development_Mode') !== true) {
return API.v1.failure({ error: 'Direct installation of an App is disabled.' });
@ -160,20 +201,27 @@ export class AppsRestApi {
return API.v1.failure({ error: 'Failed to get a file to install for the App. ' });
}
const aff = Promise.await(manager.add(buff.toString('base64'), false));
const aff = Promise.await(manager.add(buff.toString('base64'), false, marketplaceInfo));
const info = aff.getAppInfo();
// If there are compiler errors, there won't be an App to get the status of
if (aff.getApp()) {
info.status = aff.getApp().getStatus();
} else {
info.status = 'compiler_error';
if (aff.hasStorageError()) {
return API.v1.failure({ status: 'storage_error', messages: [aff.getStorageError()] });
}
if (aff.getCompilerErrors().length) {
return API.v1.failure({ status: 'compiler_error', messages: aff.getCompilerErrors() });
}
if (aff.getLicenseValidationResult().hasErrors) {
return API.v1.failure({ status: 'license_error', messages: aff.getLicenseValidationResult().getErrors() });
}
info.status = aff.getApp().getStatus();
return API.v1.success({
app: info,
implemented: aff.getImplementedInferfaces(),
compilerErrors: aff.getCompilerErrors(),
warnings: aff.getLicenseValidationResult().getWarnings(),
});
},
});
@ -216,7 +264,7 @@ export class AppsRestApi {
if (this.queryParams.marketplace && this.queryParams.version) {
const baseUrl = orchestrator.getMarketplaceUrl();
const headers = {};
const headers = getDefaultHeaders();
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
@ -236,7 +284,7 @@ export class AppsRestApi {
if (this.queryParams.marketplace && this.queryParams.update && this.queryParams.appVersion) {
const baseUrl = orchestrator.getMarketplaceUrl();
const headers = {};
const headers = getDefaultHeaders();
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
@ -264,8 +312,6 @@ export class AppsRestApi {
return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`);
},
post() {
// TODO: Verify permissions
let buff;
if (this.bodyParams.url) {
@ -283,7 +329,7 @@ export class AppsRestApi {
} else if (this.bodyParams.appId && this.bodyParams.marketplace && this.bodyParams.version) {
const baseUrl = orchestrator.getMarketplaceUrl();
const headers = {};
const headers = getDefaultHeaders();
const token = getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${ token }`;
@ -503,12 +549,13 @@ export class AppsRestApi {
const prl = manager.getOneById(this.urlParams.id);
if (prl) {
const result = Promise.await(manager.changeStatus(prl.getID(), this.bodyParams.status));
return API.v1.success({ status: result.getStatus() });
if (!prl) {
return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`);
}
return API.v1.notFound(`No App found by the id of: ${ this.urlParams.id }`);
const result = Promise.await(manager.changeStatus(prl.getID(), this.bodyParams.status));
return API.v1.success({ status: result.getStatus() });
},
});
}

@ -0,0 +1,44 @@
import { Meteor } from 'meteor/meteor';
import { HTTP } from 'meteor/http';
import { SyncedCron } from 'meteor/littledata:synced-cron';
import { Apps } from './orchestrator';
import { getWorkspaceAccessToken } from '../../cloud/server';
import { Settings } from '../../models/server';
export const appsUpdateMarketplaceInfo = Meteor.bindEnvironment(() => {
const token = getWorkspaceAccessToken();
const baseUrl = Apps.getMarketplaceUrl();
const [workspaceIdSetting] = Settings.findById('Cloud_Workspace_Id').fetch();
const fullUrl = `${ baseUrl }/v1/workspaces/${ workspaceIdSetting.value }/apps`;
const options = {
headers: {
Authorization: `Bearer ${ token }`,
},
};
let data = [];
try {
const result = HTTP.get(fullUrl, options);
if (Array.isArray(result.data)) {
data = result.data;
}
} catch (err) {
Apps.debugLog(err);
}
Promise.await(Apps.updateAppsMarketplaceInfo(data));
});
SyncedCron.add({
name: 'Apps-Engine:check',
schedule: (parser) => parser.text('at 4:00 pm'),
job() {
appsUpdateMarketplaceInfo();
},
});
SyncedCron.start();

@ -1 +1,3 @@
import './cron';
export { Apps } from './orchestrator';

@ -12,9 +12,7 @@ export let Apps;
class AppServerOrchestrator {
constructor() {
if (Permissions) {
Permissions.createOrUpdate('manage-apps', ['admin']);
}
Permissions.createOrUpdate('manage-apps', ['admin']);
this._marketplaceUrl = 'https://marketplace.rocket.chat';
@ -84,10 +82,10 @@ class AppServerOrchestrator {
return settings.get('Apps_Framework_Development_Mode');
}
debugLog() {
debugLog(...args) {
if (this.isDebugging()) {
// eslint-disable-next-line
console.log(...arguments);
console.log(...args);
}
}
@ -99,10 +97,10 @@ class AppServerOrchestrator {
// Don't try to load it again if it has
// already been loaded
if (this.isLoaded()) {
return;
return Promise.resolve();
}
this._manager.load()
return this._manager.load()
.then((affs) => console.log(`Loaded the Apps Framework and loaded a total of ${ affs.length } Apps!`))
.catch((err) => console.warn('Failed to load the Apps Framework and Apps!', err));
}
@ -111,13 +109,21 @@ class AppServerOrchestrator {
// Don't try to unload it if it's already been
// unlaoded or wasn't unloaded to start with
if (!this.isLoaded()) {
return;
return Promise.resolve();
}
this._manager.unload()
return this._manager.unload()
.then(() => console.log('Unloaded the Apps Framework.'))
.catch((err) => console.warn('Failed to unload the Apps Framework!', err));
}
updateAppsMarketplaceInfo(apps = []) {
if (!this.isLoaded()) {
return Promise.resolve();
}
return this._manager.updateAppsMarketplaceInfo(apps);
}
}
settings.addGroup('General', function() {

@ -463,6 +463,10 @@ export class Users extends Base {
return this.find(query, options);
}
findActive(options = {}) {
return this.find({ active: true }, options);
}
findActiveByUsernameOrNameRegexWithExceptions(searchTerm, exceptions, options) {
if (exceptions == null) { exceptions = []; }
if (options == null) { options = {}; }
@ -667,8 +671,12 @@ export class Users extends Base {
return this.findOne(query, options);
}
findRemote() {
return this.find({ isRemote: true });
findRemote(options = {}) {
return this.find({ isRemote: true }, options);
}
findActiveRemote(options = {}) {
return this.find({ active: true, isRemote: true }, options);
}
// UPDATE

@ -0,0 +1,3 @@
<svg id="card" viewBox="0 0 20 20">
<path d="M3.2304,9.5 L16.7696,9.5 L16.7696,5.8066875 C16.7696,5.2281875 16.3088,4.75775 15.7432,4.75775 L4.256,4.75775 C3.6904,4.75775 3.2304,5.2281875 3.2304,5.8066875 L3.2304,9.5 Z M3.2304,11 L3.2304,14.1933125 C3.2304,14.7718125 3.6904,15.2414375 4.256,15.2414375 L15.7432,15.2414375 C16.3088,15.2414375 16.7696,14.7718125 16.7696,14.1933125 L16.7696,11 L3.2304,11 Z M15.7432,16.5 L4.256,16.5 C3.0112,16.5 2,15.4656875 2,14.1933125 L2,5.8066875 C2,4.5343125 3.0112,3.5 4.256,3.5 L15.7432,3.5 C16.988,3.5 18,4.5343125 18,5.8066875 L18,14.1933125 C18,15.4656875 16.988,16.5 15.7432,16.5 Z M14.45,14.3 C13.7596441,14.3 13.2,13.7403559 13.2,13.05 C13.2,12.3596441 13.7596441,11.8 14.45,11.8 C15.1403559,11.8 15.7,12.3596441 15.7,13.05 C15.7,13.7403559 15.1403559,14.3 14.45,14.3 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

483
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -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.4.2",
"@rocket.chat/apps-engine": "^1.5.0",
"@slack/client": "^4.8.0",
"adm-zip": "^0.4.13",
"apollo-server-express": "^1.3.6",

@ -356,8 +356,14 @@
"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_Uninstall_App_Prompt": "Do you really want to uninstall this app?",
"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",
"Apps_Marketplace_pricingPlan_monthly_perUser": "__price__ / month per user",
"Apps_Marketplace_pricingPlan_yearly": "__price__ / year",
"Apps_Marketplace_pricingPlan_yearly_perUser": "__price__ / year per user",
"Apps_Settings": "App's Settings",
"Apps_WhatIsIt": "Apps: What Are They?",
"Apps_WhatIsIt_paragraph1": "A new icon in the administration area! What does this mean and what are Apps?",

@ -29,6 +29,9 @@
<symbol id="icon-calendar" viewBox="0 0 20 20" fill="currentColor">
<path d="M15.5,4 C16.328125,4 17,4.671875 17,5.5 L17,16.5 C17,17.328125 16.328125,18 15.5,18 L4.5,18 C3.671875,18 3,17.328125 3,16.5 L3,5.5 C3,4.671875 3.671875,4 4.5,4 L6,4 L6,3 C6,2.44771525 6.44771525,2 7,2 C7.55228475,2 8,2.44771525 8,3 L8,4 L12,4 L12,3 C12,2.44771525 12.4477153,2 13,2 C13.5522847,2 14,2.44771525 14,3 L14,4 L15.5,4 Z M14.5,16.5 C15.0522847,16.5 15.5,16.0522847 15.5,15.5 L15.5,7 L4.5,7 L4.5,15.5 C4.5,16.0522847 4.94771525,16.5 5.5,16.5 L14.5,16.5 Z M6.3,9 L7.7,9 C7.86568542,9 8,9.13431458 8,9.3 L8,10.7 C8,10.8656854 7.86568542,11 7.7,11 L6.3,11 C6.13431458,11 6,10.8656854 6,10.7 L6,9.3 C6,9.13431458 6.13431458,9 6.3,9 Z M6.3,12 L7.7,12 C7.86568542,12 8,12.1343146 8,12.3 L8,13.7 C8,13.8656854 7.86568542,14 7.7,14 L6.3,14 C6.13431458,14 6,13.8656854 6,13.7 L6,12.3 C6,12.1343146 6.13431458,12 6.3,12 Z M9.3,9 L10.7,9 C10.8656854,9 11,9.13431458 11,9.3 L11,10.7 C11,10.8656854 10.8656854,11 10.7,11 L9.3,11 C9.13431458,11 9,10.8656854 9,10.7 L9,9.3 C9,9.13431458 9.13431458,9 9.3,9 Z M9.3,12 L10.7,12 C10.8656854,12 11,12.1343146 11,12.3 L11,13.7 C11,13.8656854 10.8656854,14 10.7,14 L9.3,14 C9.13431458,14 9,13.8656854 9,13.7 L9,12.3 C9,12.1343146 9.13431458,12 9.3,12 Z M12.3,9 L13.7,9 C13.8656854,9 14,9.13431458 14,9.3 L14,10.7 C14,10.8656854 13.8656854,11 13.7,11 L12.3,11 C12.1343146,11 12,10.8656854 12,10.7 L12,9.3 C12,9.13431458 12.1343146,9 12.3,9 Z M12.3,12 L13.7,12 C13.8656854,12 14,12.1343146 14,12.3 L14,13.7 C14,13.8656854 13.8656854,14 13.7,14 L12.3,14 C12.1343146,14 12,13.8656854 12,13.7 L12,12.3 C12,12.1343146 12.1343146,12 12.3,12 Z"/>
</symbol>
<symbol id="icon-card" viewBox="0 0 20 20" fill="currentColor">
<path d="M3.2304,9.5 L16.7696,9.5 L16.7696,5.8066875 C16.7696,5.2281875 16.3088,4.75775 15.7432,4.75775 L4.256,4.75775 C3.6904,4.75775 3.2304,5.2281875 3.2304,5.8066875 L3.2304,9.5 Z M3.2304,11 L3.2304,14.1933125 C3.2304,14.7718125 3.6904,15.2414375 4.256,15.2414375 L15.7432,15.2414375 C16.3088,15.2414375 16.7696,14.7718125 16.7696,14.1933125 L16.7696,11 L3.2304,11 Z M15.7432,16.5 L4.256,16.5 C3.0112,16.5 2,15.4656875 2,14.1933125 L2,5.8066875 C2,4.5343125 3.0112,3.5 4.256,3.5 L15.7432,3.5 C16.988,3.5 18,4.5343125 18,5.8066875 L18,14.1933125 C18,15.4656875 16.988,16.5 15.7432,16.5 Z M14.45,14.3 C13.7596441,14.3 13.2,13.7403559 13.2,13.05 C13.2,12.3596441 13.7596441,11.8 14.45,11.8 C15.1403559,11.8 15.7,12.3596441 15.7,13.05 C15.7,13.7403559 15.1403559,14.3 14.45,14.3 Z"/>
</symbol>
<symbol id="icon-chat" viewBox="0 0 20 20" fill="currentColor">
<path d="M3.36312881,15.7593532 C3.32888874,15.7771726 3.29959994,15.7925621 3.27370934,15.8064174 C3.73031444,16.1917386 4.92393678,16.4549204 7.10274479,16.4476544 L7.21881505,16.4476539 C8.55574002,16.4476543 9.6998097,16.021859 10.6510241,15.170268 L12.188241,15.2712054 C11.5513943,15.8887667 11.2142468,16.2130071 11.1767984,16.2439265 C10.1491488,17.09241 8.76567843,17.5637907 7.21881505,17.5637907 L7.10806092,17.5644215 L6.97721254,17.5644231 C4.30241208,17.5644231 2.62246666,17.1951018 2.10341288,16.0930165 C1.82161029,15.4951149 2.13151102,15.1455492 2.84114675,14.7722012 C3.31318797,14.5227311 3.32172914,14.5103228 3.24657386,14.3473099 C3.20302404,14.2436958 3.16526615,14.1604157 3.07972552,13.9763174 C2.74839464,13.2684751 2.62592155,12.9023078 2.62592155,12.4061039 C2.62592155,11.562445 2.83045155,10.7486059 3.21214892,10.0211892 C3.42204019,9.66099218 3.78721597,9.06536542 4.270822,8.82818513 C4.270822,8.80275057 4.53792399,10.0211892 4.53792399,10 C4.45304843,10.1183326 4.36401443,10.2496015 4.270822,10.3938065 C3.78721597,11.0991197 3.7445882,11.6631394 3.7445882,12.4061039 C3.7445882,12.6985231 3.82924269,12.9394008 4.09330148,13.5047226 C4.19543836,13.7251753 4.22995648,13.801243 4.270822,13.8994264 C4.58782821,14.5810467 4.36137367,15.1036933 3.78721597,15.5080098 C3.65916127,15.5981847 3.55361796,15.6588423 3.36312881,15.7593532 Z M16.5696621,12.3226926 C16.5350205,12.3039671 16.4951623,12.2829692 16.4468239,12.2578135 C16.2265134,12.1415693 16.1048536,12.0716493 15.9574055,11.9678178 C15.2986909,11.5039571 15.0399564,10.9068098 15.4049351,10.1218159 C15.4504792,10.0121781 15.4903754,9.92425877 15.608285,9.66976084 C15.914896,9.01333804 16.0132801,8.73339397 16.0132801,8.39198996 C16.0132801,5.80517045 13.90418,3.70028722 11.3120655,3.70028722 C8.71995111,3.70028722 6.61085101,5.80517045 6.61085101,8.39198996 C6.61085101,11.1096858 8.86296092,13.082961 11.9786225,13.082961 L12.112848,13.0829616 C14.6633683,13.0914671 16.0501708,12.7797435 16.5696621,12.3226926 Z M16.5612822,10.6280948 C16.4685253,10.8294269 16.4833349,10.8509584 17.0358533,11.1429611 C17.8499187,11.5712649 18.2032866,11.969896 17.8824254,12.6506683 C17.2857779,13.9175051 15.3458564,14.3439799 12.2581228,14.3439799 L12.1073975,14.3439799 L11.9780624,14.3432467 C8.14202747,14.3432482 5.34836876,11.8401459 5.34836876,8.39198996 C5.34836876,5.10955211 8.0234498,2.44 11.3120655,2.44 C14.6008168,2.44 17.276494,5.10968724 17.276494,8.39198996 C17.276494,8.96303774 17.1353362,9.38506819 16.7528285,10.2022436 C16.6539056,10.4151439 16.6101724,10.5116034 16.5612822,10.6280948 Z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 205 KiB

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save