Support changing your integration manager in the UI

Part of https://github.com/vector-im/riot-web/issues/10161
pull/21833/head
Travis Ralston 2019-08-12 15:35:39 -06:00
parent e21c12c2c9
commit 03d735f4ed
8 changed files with 289 additions and 4 deletions

View File

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

View File

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

View File

@ -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 <div>
<InlineSpinner />
{ _t("Checking server") }
</div>;
} 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 <b>%(serverName)s</b> to manage your bots, widgets, " +
"and sticker packs.",
{serverName: currentManager.name},
{ b: sub => <b>{sub}</b> },
);
} else {
bodyText = _t(
"Add which integration manager you want to manage your bots, widgets, " +
"and sticker packs.",
);
}
return (
<form className="mx_SettingsTab_section mx_SetIntegrationManager" onSubmit={this._setManager}>
<div className="mx_SettingsTab_heading">
<span>{_t("Integration Manager")}</span>
<span className="mx_SettingsTab_subheading">{managerName}</span>
</div>
<span className="mx_SettingsTab_subsectionText">
{bodyText}
</span>
<Field label={_t("Enter a new integration manager")}
id="mx_SetIntegrationManager_newUrl"
type="text" value={this.state.url} autoComplete="off"
onChange={this._onUrlChanged}
tooltip={this._getTooltip()}
/>
<AccessibleButton
kind="primary_sm"
type="submit"
disabled={!this._canChange()}
onClick={this._setManager}
>{_t("Change")}</AccessibleButton>
</form>
);
}
}

View File

@ -204,6 +204,17 @@ export default class GeneralUserSettingsTab extends React.Component {
);
}
_renderIntegrationManagerSection() {
const SetIntegrationManager = sdk.getComponent("views.settings.SetIntegrationManager");
return (
<div className="mx_SettingsTab_section">
{ /* has its own heading as it includes the current integration manager */ }
<SetIntegrationManager />
</div>
);
}
render() {
return (
<div className="mx_SettingsTab">
@ -214,6 +225,7 @@ export default class GeneralUserSettingsTab extends React.Component {
{this._renderThemeSection()}
<div className="mx_SettingsTab_heading">{_t("Discovery")}</div>
{this._renderDiscoverySection()}
{this._renderIntegrationManagerSection() /* Has its own title */}
<div className="mx_SettingsTab_heading">{_t("Deactivate account")}</div>
{this._renderManagementSection()}
</div>

View File

@ -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 <b>%(serverName)s</b> to manage your bots, widgets, and sticker packs.": "You are currently using <b>%(serverName)s</b> 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",

View File

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

View File

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

View File

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