diff --git a/res/css/_components.scss b/res/css/_components.scss index abfce47916..579369a509 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -169,6 +169,7 @@ @import "./views/settings/_PhoneNumbers.scss"; @import "./views/settings/_ProfileSettings.scss"; @import "./views/settings/_SetIdServer.scss"; +@import "./views/settings/_SetIntegrationManager.scss"; @import "./views/settings/tabs/_SettingsTab.scss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss"; @import "./views/settings/tabs/room/_RolesRoomSettingsTab.scss"; diff --git a/res/css/views/settings/_SetIntegrationManager.scss b/res/css/views/settings/_SetIntegrationManager.scss new file mode 100644 index 0000000000..8a1380cd1f --- /dev/null +++ b/res/css/views/settings/_SetIntegrationManager.scss @@ -0,0 +1,34 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SetIntegrationManager .mx_Field_input { + margin-right: 100px; // Align with the other fields on the page +} + +.mx_SetIntegrationManager { + margin-top: 10px; + margin-bottom: 10px; +} + +.mx_SetIntegrationManager > .mx_SettingsTab_heading { + margin-bottom: 10px; +} + +.mx_SetIntegrationManager > .mx_SettingsTab_heading > .mx_SettingsTab_subheading { + display: inline-block; + padding-left: 5px; + font-size: 14px; +} diff --git a/src/components/views/settings/SetIntegrationManager.js b/src/components/views/settings/SetIntegrationManager.js new file mode 100644 index 0000000000..20300f548e --- /dev/null +++ b/src/components/views/settings/SetIntegrationManager.js @@ -0,0 +1,138 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import {_t} from "../../../languageHandler"; +import sdk from '../../../index'; +import Field from "../elements/Field"; +import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; + +export default class SetIntegrationManager extends React.Component { + constructor() { + super(); + + const currentManager = IntegrationManagers.sharedInstance().getPrimaryManager(); + + this.state = { + currentManager, + url: "", // user-entered text + error: null, + busy: false, + }; + } + + _onUrlChanged = (ev) => { + const u = ev.target.value; + this.setState({url: u}); + }; + + _getTooltip = () => { + if (this.state.busy) { + const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); + return
+ + { _t("Checking server") } +
; + } else if (this.state.error) { + return this.state.error; + } else { + return null; + } + }; + + _canChange = () => { + return !!this.state.url && !this.state.busy; + }; + + _setManager = async (ev) => { + // Don't reload the page when the user hits enter in the form. + ev.preventDefault(); + ev.stopPropagation(); + + this.setState({busy: true}); + + const manager = await IntegrationManagers.sharedInstance().tryDiscoverManager(this.state.url); + if (!manager) { + this.setState({ + busy: false, + error: _t("Integration manager offline or not accessible."), + }); + return; + } + + try { + await IntegrationManagers.sharedInstance().overwriteManagerOnAccount(manager); + this.setState({ + busy: false, + error: null, + currentManager: IntegrationManagers.sharedInstance().getPrimaryManager(), + url: "", // clear input + }); + } catch (e) { + console.error(e); + this.setState({ + busy: false, + error: _t("Failed to update integration manager"), + }); + } + }; + + render() { + const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton'); + + const currentManager = this.state.currentManager; + let managerName; + let bodyText; + if (currentManager) { + managerName = `(${currentManager.name})`; + bodyText = _t( + "You are currently using %(serverName)s to manage your bots, widgets, " + + "and sticker packs.", + {serverName: currentManager.name}, + { b: sub => {sub} }, + ); + } else { + bodyText = _t( + "Add which integration manager you want to manage your bots, widgets, " + + "and sticker packs.", + ); + } + + return ( +
+
+ {_t("Integration Manager")} + {managerName} +
+ + {bodyText} + + + {_t("Change")} + + ); + } +} diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js index 7e0d9f686f..0bf396c740 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js @@ -204,6 +204,17 @@ export default class GeneralUserSettingsTab extends React.Component { ); } + _renderIntegrationManagerSection() { + const SetIntegrationManager = sdk.getComponent("views.settings.SetIntegrationManager"); + + return ( +
+ { /* has its own heading as it includes the current integration manager */ } + +
+ ); + } + render() { return (
@@ -214,6 +225,7 @@ export default class GeneralUserSettingsTab extends React.Component { {this._renderThemeSection()}
{_t("Discovery")}
{this._renderDiscoverySection()} + {this._renderIntegrationManagerSection() /* Has its own title */}
{_t("Deactivate account")}
{this._renderManagementSection()}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 154871a977..a925010f6e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -548,6 +548,13 @@ "Identity Server": "Identity Server", "You are not currently using an Identity Server. To discover and be discoverable by existing contacts you know, add one below": "You are not currently using an Identity Server. To discover and be discoverable by existing contacts you know, add one below", "Change": "Change", + "Checking server": "Checking server", + "Integration manager offline or not accessible.": "Integration manager offline or not accessible.", + "Failed to update integration manager": "Failed to update integration manager", + "You are currently using %(serverName)s to manage your bots, widgets, and sticker packs.": "You are currently using %(serverName)s to manage your bots, widgets, and sticker packs.", + "Add which integration manager you want to manage your bots, widgets, and sticker packs.": "Add which integration manager you want to manage your bots, widgets, and sticker packs.", + "Integration Manager": "Integration Manager", + "Enter a new integration manager": "Enter a new integration manager", "Flair": "Flair", "Failed to change password. Is your password correct?": "Failed to change password. Is your password correct?", "Success": "Success", diff --git a/src/integrations/IntegrationManagerInstance.js b/src/integrations/IntegrationManagerInstance.js index 4d0181f017..c21fff0fd3 100644 --- a/src/integrations/IntegrationManagerInstance.js +++ b/src/integrations/IntegrationManagerInstance.js @@ -19,12 +19,18 @@ import sdk from "../index"; import {dialogTermsInteractionCallback, TermsNotSignedError} from "../Terms"; import type {Room} from "matrix-js-sdk"; import Modal from '../Modal'; +import url from 'url'; + +export const KIND_ACCOUNT = "account"; +export const KIND_CONFIG = "config"; export class IntegrationManagerInstance { apiUrl: string; uiUrl: string; + kind: string; - constructor(apiUrl: string, uiUrl: string) { + constructor(kind: string, apiUrl: string, uiUrl: string) { + this.kind = kind; this.apiUrl = apiUrl; this.uiUrl = uiUrl; @@ -32,6 +38,11 @@ export class IntegrationManagerInstance { if (!this.uiUrl) this.uiUrl = this.apiUrl; } + get name(): string { + const parsed = url.parse(this.uiUrl); + return parsed.hostname; + } + getScalarClient(): ScalarAuthClient { return new ScalarAuthClient(this.apiUrl, this.uiUrl); } diff --git a/src/integrations/IntegrationManagers.js b/src/integrations/IntegrationManagers.js index 9c9a1fa228..9b852fe61a 100644 --- a/src/integrations/IntegrationManagers.js +++ b/src/integrations/IntegrationManagers.js @@ -17,7 +17,7 @@ limitations under the License. import SdkConfig from '../SdkConfig'; import sdk from "../index"; import Modal from '../Modal'; -import {IntegrationManagerInstance} from "./IntegrationManagerInstance"; +import {IntegrationManagerInstance, KIND_ACCOUNT, KIND_CONFIG} from "./IntegrationManagerInstance"; import type {MatrixClient, MatrixEvent} from "matrix-js-sdk"; import WidgetUtils from "../utils/WidgetUtils"; import MatrixClientPeg from "../MatrixClientPeg"; @@ -62,7 +62,7 @@ export class IntegrationManagers { const uiUrl = SdkConfig.get()['integrations_ui_url']; if (apiUrl && uiUrl) { - this._managers.push(new IntegrationManagerInstance(apiUrl, uiUrl)); + this._managers.push(new IntegrationManagerInstance(KIND_CONFIG, apiUrl, uiUrl)); } } @@ -77,7 +77,7 @@ export class IntegrationManagers { const apiUrl = data['api_url']; if (!apiUrl || !uiUrl) return; - this._managers.push(new IntegrationManagerInstance(apiUrl, uiUrl)); + this._managers.push(new IntegrationManagerInstance(KIND_ACCOUNT, apiUrl, uiUrl)); }); } @@ -107,6 +107,74 @@ export class IntegrationManagers { {configured: false}, 'mx_IntegrationsManager', ); } + + async overwriteManagerOnAccount(manager: IntegrationManagerInstance) { + // TODO: TravisR - We should be logging out of scalar clients. + await WidgetUtils.removeIntegrationManagerWidgets(); + + // TODO: TravisR - We should actually be carrying over the discovery response verbatim. + await WidgetUtils.setUserWidget( + "integration_manager_" + (new Date().getTime()), + "m.integration_manager", + manager.uiUrl, + "Integration Manager", + {"api_url": manager.apiUrl}, + ); + } + + /** + * Attempts to discover an integration manager using only its name. + * @param {string} domainName The domain name to look up. + * @returns {Promise} Resolves to an integration manager instance, + * or null if none was found. + */ + async tryDiscoverManager(domainName: string): IntegrationManagerInstance { + console.log("Looking up integration manager via .well-known"); + if (domainName.startsWith("http:") || domainName.startsWith("https:")) { + // trim off the scheme and just use the domain + const url = url.parse(domainName); + domainName = url.host; + } + + let wkConfig; + try { + const result = await fetch(`https://${domainName}/.well-known/matrix/integrations`); + wkConfig = await result.json(); + } catch (e) { + console.error(e); + console.warn("Failed to locate integration manager"); + return null; + } + + if (!wkConfig || !wkConfig["m.integrations_widget"]) { + console.warn("Missing integrations widget on .well-known response"); + return null; + } + + const widget = wkConfig["m.integrations_widget"]; + if (!widget["url"] || !widget["data"] || !widget["data"]["api_url"]) { + console.warn("Malformed .well-known response for integrations widget"); + return null; + } + + // All discovered managers are per-user managers + const manager = new IntegrationManagerInstance(KIND_ACCOUNT, widget["data"]["api_url"], widget["url"]); + console.log("Got integration manager response, checking for responsiveness"); + + // Test the manager + const client = manager.getScalarClient(); + try { + // not throwing an error is a success here + await client.connect(); + } catch (e) { + console.error(e); + console.warn("Integration manager failed liveliness check"); + return null; + } + + console.log("Integration manager is alive and functioning"); + return manager; + } } // For debugging diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index 1e47554914..edac449ccf 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -351,6 +351,20 @@ export default class WidgetUtils { return widgets.filter(w => w.content && imTypes.includes(w.content.type)); } + static removeIntegrationManagerWidgets() { + const client = MatrixClientPeg.get(); + if (!client) { + throw new Error('User not logged in'); + } + const userWidgets = client.getAccountData('m.widgets').getContent() || {}; + Object.entries(userWidgets).forEach(([key, widget]) => { + if (widget.content && widget.content.type === 'm.integration_manager') { + delete userWidgets[key]; + } + }); + return client.setAccountData('m.widgets', userWidgets); + } + /** * Remove all stickerpicker widgets (stickerpickers are user widgets by nature) * @return {Promise} Resolves on account data updated