From b0d71bc9431a26a2b9eb2e256a1d0cd117777bb7 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 2 Apr 2019 11:16:41 -0500 Subject: [PATCH] [FIX] Improve cloud section (#13820) * Improve cloud section * add sync and fix i18n and a few other issues * set oauth scopes * add suggested grammar and federation hub scope * Switch to postform instead of query string to post oauth more securely --- app/cloud/client/admin/cloud.html | 40 ++++++--- app/cloud/client/admin/cloud.js | 85 ++++++++++++++++--- .../server/functions/connectWorkspace.js | 33 +++---- .../server/functions/disconnectWorkspace.js | 13 +++ .../functions/finishOAuthAuthorization.js | 16 ++-- .../functions/getOAuthAuthorizationUrl.js | 5 +- .../functions/getWorkspaceAccessToken.js | 28 ++++-- .../functions/retrieveRegistrationStatus.js | 6 +- .../functions/startRegisterWorkspace.js | 66 ++++++++++++++ app/cloud/server/functions/syncWorkspace.js | 58 +++++++++++++ .../server/functions/unregisterWorkspace.js | 22 +++++ app/cloud/server/methods.js | 39 +++++++++ app/cloud/server/oauthScopes.js | 16 ++++ app/setup-wizard/client/setupWizard.js | 14 ++- app/statistics/server/functions/get.js | 1 + packages/rocketchat-i18n/i18n/en.i18n.json | 7 +- 16 files changed, 385 insertions(+), 64 deletions(-) create mode 100644 app/cloud/server/functions/disconnectWorkspace.js create mode 100644 app/cloud/server/functions/startRegisterWorkspace.js create mode 100644 app/cloud/server/functions/syncWorkspace.js create mode 100644 app/cloud/server/functions/unregisterWorkspace.js create mode 100644 app/cloud/server/oauthScopes.js diff --git a/app/cloud/client/admin/cloud.html b/app/cloud/client/admin/cloud.html index c09e1281a77..12225ab37f8 100644 --- a/app/cloud/client/admin/cloud.html +++ b/app/cloud/client/admin/cloud.html @@ -16,25 +16,41 @@
- {{#if info.registeredWithWizard}} - {{#if info.workspaceConnected}} + {{#if info.connectToCloud}} + {{#if info.workspaceRegistered}}
- {{#if info.userAssociated}} -

{{_ "Cloud_workspace_connected_plus_account"}}

- {{else}} -

{{_ "Cloud_workspace_connected_without_account"}}

+

{{_ "Cloud_workspace_connected"}}

+ +
+ +
+

{{_ "Cloud_workspace_support"}}

- +
- {{/if}} +
+ +
+

{{_ "Cloud_workspace_disconnect"}}

+
+ +
+ +
+
{{else}}
- +
{{_ "Cloud_address_to_send_registration_to"}}
@@ -47,8 +63,6 @@
-
{{ registeredWithWizard }}
-
@@ -62,6 +76,8 @@
+ +

If you still haven't received a registration email please make sure your email is updated above. If you still have issues you can contact support at: support@rocket.chat

{{/if}} {{else}} @@ -72,7 +88,7 @@

{{_ "Cloud_registration_required_description"}}

-

{{_ "Cloud_registration_requried_link_text"}}

+
{{/if}} diff --git a/app/cloud/client/admin/cloud.js b/app/cloud/client/admin/cloud.js index ea835f67720..1975e12fc8c 100644 --- a/app/cloud/client/admin/cloud.js +++ b/app/cloud/client/admin/cloud.js @@ -34,7 +34,67 @@ Template.cloud.onCreated(function() { } if (!success) { - toastr.error('Invalid token'); + toastr.error('An error occured connecting'); + instance.loadRegStatus(); + return; + } + + toastr.success(t('Connected')); + + instance.loadRegStatus(); + }); + }; + + instance.disconnectWorkspace = function _disconnectWorkspace() { + Meteor.call('cloud:disconnectWorkspace', (error, success) => { + if (error) { + toastr.error(error); + instance.loadRegStatus(); + return; + } + + if (!success) { + toastr.error('An error occured disconnecting'); + instance.loadRegStatus(); + return; + } + + toastr.success(t('Disconnected')); + + instance.loadRegStatus(); + }); + }; + + instance.syncWorkspace = function _syncWorkspace() { + Meteor.call('cloud:syncWorkspace', (error, success) => { + if (error) { + toastr.error(error); + instance.loadRegStatus(); + return; + } + + if (!success) { + toastr.error('An error occured syncing'); + instance.loadRegStatus(); + return; + } + + toastr.success(t('Sync Complete')); + + instance.loadRegStatus(); + }); + }; + + instance.registerWorkspace = function _registerWorkspace() { + Meteor.call('cloud:registerWorkspace', (error, success) => { + if (error) { + toastr.error(error); + instance.loadRegStatus(); + return; + } + + if (!success) { + toastr.error('An error occured'); instance.loadRegStatus(); return; } @@ -74,20 +134,21 @@ Template.cloud.events({ }); }, - 'click .login-btn'() { - Meteor.call('cloud:getOAuthAuthorizationUrl', (error, url) => { - if (error) { - console.warn(error); - return; - } - - window.location.href = url; - }); - }, - 'click .connect-btn'(e, i) { const token = $('input[name=cloudToken]').val(); i.connectWorkspace(token); }, + + 'click .register-btn'(e, i) { + i.registerWorkspace(); + }, + + 'click .disconnect-btn'(e, i) { + i.disconnectWorkspace(); + }, + + 'click .sync-btn'(e, i) { + i.syncWorkspace(); + }, }); diff --git a/app/cloud/server/functions/connectWorkspace.js b/app/cloud/server/functions/connectWorkspace.js index 84b2d0f68d3..e12c5fadf41 100644 --- a/app/cloud/server/functions/connectWorkspace.js +++ b/app/cloud/server/functions/connectWorkspace.js @@ -1,16 +1,15 @@ -import querystring from 'querystring'; - import { HTTP } from 'meteor/http'; import { settings } from '../../../settings'; import { Settings } from '../../../models'; import { getRedirectUri } from './getRedirectUri'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; export function connectWorkspace(token) { - const { registeredWithWizard } = retrieveRegistrationStatus(); - if (!registeredWithWizard) { - return false; + const { connectToCloud } = retrieveRegistrationStatus(); + if (!connectToCloud) { + Settings.updateValueById('Register_Server', true); } const redirectUri = getRedirectUri(); @@ -31,6 +30,10 @@ export function connectWorkspace(token) { data: regInfo, }); } catch (e) { + if (e.response && e.response.data && e.response.data.error) { + console.error(`Failed to register with Rocket.Chat Cloud. Error: ${ e.response.data.error }`); + } + return false; } @@ -48,26 +51,10 @@ export function connectWorkspace(token) { Settings.updateValueById('Cloud_Workspace_Registration_Client_Uri', data.registration_client_uri); // Now that we have the client id and secret, let's get the access token - let authTokenResult; - try { - authTokenResult = HTTP.post(`${ cloudUrl }/api/oauth/token`, { - data: {}, - query: querystring.stringify({ - client_id: data.client_id, - client_secret: data.client_secret, - grant_type: 'client_credentials', - redirect_uri: redirectUri, - }), - }); - } catch (e) { + const accessToken = getWorkspaceAccessToken(true); + if (!accessToken) { return false; } - 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); - return true; } diff --git a/app/cloud/server/functions/disconnectWorkspace.js b/app/cloud/server/functions/disconnectWorkspace.js new file mode 100644 index 00000000000..c1e2adda876 --- /dev/null +++ b/app/cloud/server/functions/disconnectWorkspace.js @@ -0,0 +1,13 @@ +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { Settings } from '../../../models'; + +export function disconnectWorkspace() { + const { connectToCloud } = retrieveRegistrationStatus(); + if (!connectToCloud) { + return true; + } + + Settings.updateValueById('Register_Server', false); + + return true; +} diff --git a/app/cloud/server/functions/finishOAuthAuthorization.js b/app/cloud/server/functions/finishOAuthAuthorization.js index c6bb4b34328..e9f7deb9d43 100644 --- a/app/cloud/server/functions/finishOAuthAuthorization.js +++ b/app/cloud/server/functions/finishOAuthAuthorization.js @@ -1,11 +1,10 @@ -import querystring from 'querystring'; - import { Meteor } from 'meteor/meteor'; import { HTTP } from 'meteor/http'; import { settings } from '../../../settings'; import { Settings, Users } from '../../../models'; import { getRedirectUri } from './getRedirectUri'; +import { userScopes } from '../oauthScopes'; export function finishOAuthAuthorization(code, state) { if (settings.get('Cloud_Workspace_Registration_State') !== state) { @@ -16,19 +15,26 @@ export function finishOAuthAuthorization(code, state) { const clientId = settings.get('Cloud_Workspace_Client_Id'); const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); + const scope = userScopes.join(' '); + let result; try { result = HTTP.post(`${ cloudUrl }/api/oauth/token`, { - data: {}, - query: querystring.stringify({ + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + params: { client_id: clientId, client_secret: clientSecret, grant_type: 'authorization_code', + scope, code, redirect_uri: getRedirectUri(), - }), + }, }); } catch (e) { + if (e.response && e.response.data && e.response.data.error) { + console.error(`Failed to get AccessToken from Rocket.Chat Cloud. Error: ${ e.response.data.error }`); + } + return false; } diff --git a/app/cloud/server/functions/getOAuthAuthorizationUrl.js b/app/cloud/server/functions/getOAuthAuthorizationUrl.js index 9da757fb9a2..b0f3b687989 100644 --- a/app/cloud/server/functions/getOAuthAuthorizationUrl.js +++ b/app/cloud/server/functions/getOAuthAuthorizationUrl.js @@ -3,6 +3,7 @@ import { Settings } from '../../../models'; import { settings } from '../../../settings'; import { getRedirectUri } from './getRedirectUri'; +import { userScopes } from '../oauthScopes'; export function getOAuthAuthorizationUrl() { const state = Random.id(); @@ -13,5 +14,7 @@ export function getOAuthAuthorizationUrl() { const client_id = settings.get('Cloud_Workspace_Client_Id'); const redirectUri = getRedirectUri(); - return `${ cloudUrl }/authorize?response_type=code&client_id=${ client_id }&redirect_uri=${ redirectUri }&scope=offline_access&state=${ state }`; + const scope = userScopes.join(' '); + + return `${ cloudUrl }/authorize?response_type=code&client_id=${ client_id }&redirect_uri=${ redirectUri }&scope=${ scope }&state=${ state }`; } diff --git a/app/cloud/server/functions/getWorkspaceAccessToken.js b/app/cloud/server/functions/getWorkspaceAccessToken.js index e9b174c4e9b..282479342fd 100644 --- a/app/cloud/server/functions/getWorkspaceAccessToken.js +++ b/app/cloud/server/functions/getWorkspaceAccessToken.js @@ -1,13 +1,16 @@ -import querystring from 'querystring'; - import { HTTP } from 'meteor/http'; import { settings } from '../../../settings'; import { Settings } from '../../../models'; import { getRedirectUri } from './getRedirectUri'; +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { unregisterWorkspace } from './unregisterWorkspace'; +import { workspaceScopes } from '../oauthScopes'; export function getWorkspaceAccessToken(forceNew = false, scope = '', save = true) { - if (!settings.get('Register_Server')) { + const { connectToCloud, workspaceRegistered } = retrieveRegistrationStatus(); + + if (!connectToCloud || !workspaceRegistered) { return ''; } @@ -27,19 +30,32 @@ export function getWorkspaceAccessToken(forceNew = false, scope = '', save = tru const client_secret = settings.get('Cloud_Workspace_Client_Secret'); const redirectUri = getRedirectUri(); + if (scope === '') { + scope = workspaceScopes.join(' '); + } + let authTokenResult; try { authTokenResult = HTTP.post(`${ cloudUrl }/api/oauth/token`, { - data: {}, - query: querystring.stringify({ + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + params: { client_id, client_secret, scope, grant_type: 'client_credentials', redirect_uri: redirectUri, - }), + }, }); } catch (e) { + if (e.response && e.response.data && e.response.data.error) { + console.error(`Failed to get AccessToken from Rocket.Chat Cloud. Error: ${ e.response.data.error }`); + + if (e.response.data.error === 'oauth_invalid_client_credentials') { + console.error('Server has been unregistered from cloud'); + unregisterWorkspace(); + } + } + return ''; } diff --git a/app/cloud/server/functions/retrieveRegistrationStatus.js b/app/cloud/server/functions/retrieveRegistrationStatus.js index 2cc250927f6..c0c1af3636a 100644 --- a/app/cloud/server/functions/retrieveRegistrationStatus.js +++ b/app/cloud/server/functions/retrieveRegistrationStatus.js @@ -3,9 +3,11 @@ import { Users } from '../../../models'; export function retrieveRegistrationStatus() { const info = { - registeredWithWizard: settings.get('Register_Server'), - workspaceConnected: (settings.get('Cloud_Workspace_Client_Id')) ? true : false, + connectToCloud: settings.get('Register_Server'), + workspaceRegistered: (settings.get('Cloud_Workspace_Client_Id')) ? true : false, userAssociated: (settings.get('Cloud_Workspace_Account_Associated')) ? true : false, + workspaceId: settings.get('Cloud_Workspace_Id'), + uniqueId: settings.get('uniqueID'), token: '', email: '', }; diff --git a/app/cloud/server/functions/startRegisterWorkspace.js b/app/cloud/server/functions/startRegisterWorkspace.js new file mode 100644 index 00000000000..609e380b43c --- /dev/null +++ b/app/cloud/server/functions/startRegisterWorkspace.js @@ -0,0 +1,66 @@ +import { HTTP } from 'meteor/http'; +import { settings } from '../../../settings'; +import { Settings } from '../../../models'; + +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; + +import { statistics } from '../../../statistics'; + +export function startRegisterWorkspace() { + const { workspaceRegistered, connectToCloud } = retrieveRegistrationStatus(); + if ((workspaceRegistered && connectToCloud) || process.env.TEST_MODE) { + return true; + } + + settings.updateById('Register_Server', true); + + if (workspaceRegistered) { + return true; + } + + const stats = statistics.get(); + + const address = settings.get('Site_Url'); + + // If we have it lets send it because likely an update + const workspaceId = settings.get('Cloud_Workspace_Id'); + + const regInfo = { + uniqueId: stats.uniqueId, + workspaceId, + address, + contactName: stats.wizard.contactName, + contactEmail: stats.wizard.contactEmail, + accountName: stats.wizard.organizationName, + siteName: stats.wizard.siteName, + deploymentMethod: stats.deploy.method, + deploymentPlatform: stats.deploy.platform, + version: stats.version, + }; + + const cloudUrl = settings.get('Cloud_Url'); + + let result; + try { + result = HTTP.post(`${ cloudUrl }/api/v2/register/workspace`, { + data: regInfo, + }); + } catch (e) { + if (e.response && e.response.data && e.response.data.errorCode) { + console.error(`Failed to register with Rocket.Chat Cloud. ErrorCode: ${ e.response.data.errorCode }`); + } + return false; + } + + const { data } = result; + + if (!data) { + return false; + } + + Settings.updateValueById('Cloud_Workspace_Id', data.id); + + console.log(data); + + return true; +} diff --git a/app/cloud/server/functions/syncWorkspace.js b/app/cloud/server/functions/syncWorkspace.js new file mode 100644 index 00000000000..dc285dff31e --- /dev/null +++ b/app/cloud/server/functions/syncWorkspace.js @@ -0,0 +1,58 @@ +import { HTTP } from 'meteor/http'; +import { settings } from '../../../settings'; + +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; +import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; + +import { statistics } from '../../../statistics'; +import { getWorkspaceLicense } from './getWorkspaceLicense'; + +export function syncWorkspace() { + const { workspaceRegistered, connectToCloud } = retrieveRegistrationStatus(); + if (!workspaceRegistered || !connectToCloud) { + return false; + } + + const stats = statistics.get(); + + const address = settings.get('Site_Url'); + + const info = { + uniqueId: stats.uniqueId, + address, + contactName: stats.wizard.contactName, + contactEmail: stats.wizard.contactEmail, + accountName: stats.wizard.organizationName, + siteName: stats.wizard.siteName, + deploymentMethod: stats.deploy.method, + deploymentPlatform: stats.deploy.platform, + version: stats.version, + }; + + const workspaceUrl = settings.get('Cloud_Workspace_Registration_Client_Uri'); + + try { + const headers = {}; + const token = getWorkspaceAccessToken(true); + + if (token) { + headers.Authorization = `Bearer ${ token }`; + } else { + return false; + } + + HTTP.post(`${ workspaceUrl }/registration`, { + data: info, + headers, + }); + + } catch (e) { + if (e.response && e.response.data && e.response.data.error) { + console.error(`Failed to sync with Rocket.Chat Cloud. Error: ${ e.response.data.error }`); + } + + return false; + } + + return getWorkspaceLicense(); +} diff --git a/app/cloud/server/functions/unregisterWorkspace.js b/app/cloud/server/functions/unregisterWorkspace.js new file mode 100644 index 00000000000..0415ea4fcab --- /dev/null +++ b/app/cloud/server/functions/unregisterWorkspace.js @@ -0,0 +1,22 @@ +import { Settings } from '../../../models'; + +import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; + +export function unregisterWorkspace() { + const { workspaceRegistered } = retrieveRegistrationStatus(); + if (!workspaceRegistered) { + return true; + } + + Settings.updateValueById('Cloud_Workspace_Id', null); + Settings.updateValueById('Cloud_Workspace_Name', null); + Settings.updateValueById('Cloud_Workspace_Client_Id', null); + Settings.updateValueById('Cloud_Workspace_Client_Secret', null); + Settings.updateValueById('Cloud_Workspace_Client_Secret_Expires_At', null); + Settings.updateValueById('Cloud_Workspace_Registration_Client_Uri', null); + + // So doesn't try to register again automatically + Settings.updateValueById('Register_Server', false); + + return true; +} diff --git a/app/cloud/server/methods.js b/app/cloud/server/methods.js index 843b908af07..09c7498fab0 100644 --- a/app/cloud/server/methods.js +++ b/app/cloud/server/methods.js @@ -7,6 +7,9 @@ import { retrieveRegistrationStatus } from './functions/retrieveRegistrationStat import { connectWorkspace } from './functions/connectWorkspace'; import { getOAuthAuthorizationUrl } from './functions/getOAuthAuthorizationUrl'; import { finishOAuthAuthorization } from './functions/finishOAuthAuthorization'; +import { startRegisterWorkspace } from './functions/startRegisterWorkspace'; +import { disconnectWorkspace } from './functions/disconnectWorkspace'; +import { syncWorkspace } from './functions/syncWorkspace'; Meteor.methods({ 'cloud:checkRegisterStatus'() { @@ -20,6 +23,17 @@ Meteor.methods({ return retrieveRegistrationStatus(); }, + 'cloud:registerWorkspace'() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cloud:startRegister' }); + } + + if (!hasPermission(Meteor.userId(), 'manage-cloud')) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'cloud:startRegister' }); + } + + return startRegisterWorkspace(); + }, 'cloud:updateEmail'(email) { check(email, String); @@ -32,6 +46,19 @@ Meteor.methods({ } Settings.updateValueById('Organization_Email', email); + + return startRegisterWorkspace(); + }, + 'cloud:syncWorkspace'() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cloud:updateEmail' }); + } + + if (!hasPermission(Meteor.userId(), 'manage-cloud')) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'cloud:updateEmail' }); + } + + return syncWorkspace(); }, 'cloud:connectWorkspace'(token) { check(token, String); @@ -46,6 +73,18 @@ Meteor.methods({ return connectWorkspace(token); }, + 'cloud:disconnectWorkspace'() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cloud:connectServer' }); + } + + if (!hasPermission(Meteor.userId(), 'manage-cloud')) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'cloud:connectServer' }); + } + + return disconnectWorkspace(); + }, + // Currently unused but will link local account to Rocket.Chat Cloud account. 'cloud:getOAuthAuthorizationUrl'() { if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'cloud:connectServer' }); diff --git a/app/cloud/server/oauthScopes.js b/app/cloud/server/oauthScopes.js new file mode 100644 index 00000000000..3c7dd0813c4 --- /dev/null +++ b/app/cloud/server/oauthScopes.js @@ -0,0 +1,16 @@ +// These are the scopes we by default request access to +export const workspaceScopes = [ + 'workspace:license:read', + 'workspace:client:write', + 'workspace:stats:write', + 'workspace:push:send', + 'marketplace:read', + 'marketplace:download', + 'fedhub:register', +]; + +// These are the scopes we use for the user +export const userScopes = [ + 'openid', + 'offline_access', +]; diff --git a/app/setup-wizard/client/setupWizard.js b/app/setup-wizard/client/setupWizard.js index 16b472cee54..0d6d8dd1e32 100644 --- a/app/setup-wizard/client/setupWizard.js +++ b/app/setup-wizard/client/setupWizard.js @@ -193,7 +193,19 @@ Template.setupWizard.events({ persistSettings(t.state.all(), () => { localStorage.removeItem('wizard'); localStorage.setItem('wizardFinal', true); - FlowRouter.go('setup-wizard-final'); + + if (t.state.get('registerServer')) { + Meteor.call('cloud:registerWorkspace', (error) => { + if (error) { + console.warn(error); + return; + } + + FlowRouter.go('setup-wizard-final'); + }); + } else { + FlowRouter.go('setup-wizard-final'); + } }); return false; } diff --git a/app/statistics/server/functions/get.js b/app/statistics/server/functions/get.js index d9a2f41b2a0..8e090f9b288 100644 --- a/app/statistics/server/functions/get.js +++ b/app/statistics/server/functions/get.js @@ -20,6 +20,7 @@ const wizardFields = [ 'Language', 'Server_Type', 'Allow_Marketing_Emails', + 'Register_Server', ]; statistics.get = function _getStatistics() { diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 1f037b8568c..ba43d52c85c 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -602,8 +602,9 @@ "Cloud_connect": "Rocket.Chat Cloud Connect", "Cloud_what_is_it": "What is this?", "Cloud_what_is_it_description": "Rocket.Chat Cloud Connect allows you to connect your self-hosted Rocket.Chat Workspace to our Cloud. Doing so enables you to manage your licenses, Billing and Support in Rocket.Chat Cloud.", - "Cloud_workspace_connected_plus_account": "Your workspace is now connected to the Rocket.Chat Cloud and an account is associated.", - "Cloud_workspace_connected_without_account": "Your workspace is now connected to the Rocket.Chat Cloud. If you would like, you can login to the Rocket.Chat Cloud and associate your workspace with your Cloud account.", + "Cloud_workspace_connected": "Your workspace has been successfully connected to Rocket.Chat Cloud. You can access the cloud to manage account information", + "Cloud_workspace_support": "If you have any trouble with a cloud service, please try to sync first. Should the issue persist, please open a support ticket in the Cloud Console.", + "Cloud_workspace_disconnect": "If you no longer wish to utilize cloud services you can disconnect your workspace from the Rocket.Chat Cloud.", "Cloud_login_to_cloud": "Login to Rocket.Chat Cloud", "Cloud_address_to_send_registration_to": "The address to send your Cloud registration email to.", "Cloud_update_email": "Update Email", @@ -1037,6 +1038,7 @@ "Disabled": "Disabled", "Disallow_reacting": "Disallow Reacting", "Disallow_reacting_Description": "Disallows reacting", + "Disconnect": "Disconnect", "Display_offline_form": "Display Offline Form", "Display_unread_counter": "Display number of unread messages", "Displays_action_text": "Displays action text", @@ -2719,6 +2721,7 @@ "Survey": "Survey", "Survey_instructions": "Rate each question according to your satisfaction, 1 meaning you are completely unsatisfied and 5 meaning you are completely satisfied.", "Symbols": "Symbols", + "Sync": "Sync", "Sync / Import": "Sync / Import", "Sync_in_progress": "Synchronization in progress", "Sync_Interval": "Sync interval",