From 54aaabac747afffb0c7b18772f5211b1bd976927 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Jul 2019 18:51:56 +0100 Subject: [PATCH 01/35] Initial support for ToS dialogs for IS/IM as per MSC2140 --- res/css/_components.scss | 1 + res/css/views/dialogs/_TermsDialog.scss | 35 +++ src/ScalarAuthClient.js | 48 +++-- src/components/views/dialogs/TermsDialog.js | 201 ++++++++++++++++++ .../views/elements/ManageIntegsButton.js | 6 +- src/components/views/rooms/AppsDrawer.js | 6 +- .../views/settings/IntegrationsManager.js | 68 ++---- src/i18n/strings/en_EN.json | 16 +- src/integrations/integrations.js | 55 +++++ src/languageHandler.js | 35 +++ 10 files changed, 395 insertions(+), 76 deletions(-) create mode 100644 res/css/views/dialogs/_TermsDialog.scss create mode 100644 src/components/views/dialogs/TermsDialog.js create mode 100644 src/integrations/integrations.js diff --git a/res/css/_components.scss b/res/css/_components.scss index d30684993d..4c2829b68c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -70,6 +70,7 @@ @import "./views/dialogs/_SetPasswordDialog.scss"; @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; +@import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UnknownDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; diff --git a/res/css/views/dialogs/_TermsDialog.scss b/res/css/views/dialogs/_TermsDialog.scss new file mode 100644 index 0000000000..60dec57b66 --- /dev/null +++ b/res/css/views/dialogs/_TermsDialog.scss @@ -0,0 +1,35 @@ +/* +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_TermsDialog_termsTableHeader { + font-weight: bold; + text-align: left; +} + +.mx_TermsDialog_termsTable { + font-size: 12px; +} + +.mx_TermsDialog_service, .mx_TermsDialog_summary { + padding-right: 10px; +} + +.mx_TermsDialog_link { + mask-image: url('$(res)/img/external-link.svg'); + background-color: $accent-color; + width: 10px; + height: 10px; +} diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index e2b2bf0eb2..bab6ce3f67 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +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. @@ -16,11 +17,14 @@ limitations under the License. import Promise from 'bluebird'; import SettingsStore from "./settings/SettingsStore"; +import { Service, presentTermsForServices, TermsNotSignedError } from './Terms'; const request = require('browser-request'); const SdkConfig = require('./SdkConfig'); const MatrixClientPeg = require('./MatrixClientPeg'); +import * as Matrix from 'matrix-js-sdk'; + // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; @@ -55,23 +59,11 @@ class ScalarAuthClient { if (!token) { return this.registerForToken(); } else { - return this.validateToken(token).then(userId => { - const me = MatrixClientPeg.get().getUserId(); - if (userId !== me) { - throw new Error("Scalar token is owned by someone else: " + me); - } - return token; - }).catch(err => { - console.error(err); - - // Something went wrong - try to get a new token. - console.warn("Registering for new scalar token"); - return this.registerForToken(); - }); + return this._checkToken(token); } } - validateToken(token) { + _getAccountName(token) { const url = SdkConfig.get().integrations_rest_url + "/account"; return new Promise(function(resolve, reject) { @@ -83,8 +75,10 @@ class ScalarAuthClient { }, (err, response, body) => { if (err) { reject(err); + } else if (body && body.errcode === 'M_TERMS_NOT_SIGNED') { + reject(new TermsNotSignedError()); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(body); } else if (!body || !body.user_id) { reject(new Error("Missing user_id in response")); } else { @@ -94,11 +88,35 @@ class ScalarAuthClient { }); } + _checkToken(token) { + return this._getAccountName(token).then(userId => { + const me = MatrixClientPeg.get().getUserId(); + if (userId !== me) { + throw new Error("Scalar token is owned by someone else: " + me); + } + return token; + }).catch((e) => { + if (e instanceof TermsNotSignedError) { + console.log("Integrations manager requires new terms to be agreed to"); + return presentTermsForServices([new Service( + Matrix.SERVICETYPES.IM, + SdkConfig.get().integrations_rest_url, + token, + )]).then(() => { + return token; + }); + } + }); + } + registerForToken() { // Get openid bearer token from the HS as the first part of our dance return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(tokenObject); + }).then((tokenObject) => { + // Validate it (this mostly checks to see if the IM needs us to agree to some terms) + return this._checkToken(tokenObject); }).then((tokenObject) => { window.localStorage.setItem("mx_scalar_token", tokenObject); return tokenObject; diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js new file mode 100644 index 0000000000..de93957603 --- /dev/null +++ b/src/components/views/dialogs/TermsDialog.js @@ -0,0 +1,201 @@ +/* +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 url from 'url'; +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t, pickBestLanguage } from '../../../languageHandler'; + +import Matrix from 'matrix-js-sdk'; + +class TermsCheckbox extends React.Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + url: PropTypes.string.isRequired, + checked: PropTypes.bool.isRequired, + } + + onChange = (ev) => { + this.props.onChange(this.props.url, ev.target.checked); + } + + render() { + return ; + } +} + +export default class TermsDialog extends React.Component { + static propTypes = { + /** + * Array of TermsWithService + */ + termsWithServices: PropTypes.arrayOf(PropTypes.object).isRequired, + + /** + * Called with: + * * success {bool} True if the user accepted any douments, false if cancelled + * * agreedUrls {string[]} List of agreed URLs + */ + onFinished: PropTypes.func.isRequired, + } + + constructor() { + super(); + this.state = { + // url -> boolean + agreedUrls: {}, + }; + } + + _onCancelClick = () => { + this.props.onFinished(false); + } + + _onNextClick = () => { + this.props.onFinished(true, Object.keys(this.state.agreedUrls).filter((url) => this.state.agreedUrls[url])); + } + + _nameForServiceType(serviceType, host) { + switch (serviceType) { + case Matrix.SERVICETYPES.IS: + return
{_t("Identity Server")}
({host})
; + case Matrix.SERVICETYPES.IM: + return
{_t("Integrations Manager")}
({host})
; + } + } + + _summaryForServiceType(serviceType, docName) { + switch (serviceType) { + case Matrix.SERVICETYPES.IS: + return
+ {_t("Find others by phone or email")} +
+ {_t("Be found by phone or email")} + {docName !== null ?
: ''} + {docName !== null ? '('+docName+')' : ''} +
; + case Matrix.SERVICETYPES.IM: + return
+ {_t("Use Bots, bridges, widgets and sticker packs")} + {docName !== null ?
: ''} + {docName !== null ? '('+docName+')' : ''} +
; + } + } + + _onTermsCheckboxChange = (url, checked) => { + this.state.agreedUrls[url] = checked; + this.setState({agreedUrls: this.state.agreedUrls}); + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton'); + + const rows = []; + for (const termsWithService of this.props.termsWithServices) { + const parsedBaseUrl = url.parse(termsWithService.service.baseUrl); + + const termsValues = Object.values(termsWithService.terms); + for (let i = 0; i < termsValues.length; ++i) { + const termDoc = termsValues[i]; + const termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== 'version')); + let serviceName; + if (i === 0) { + serviceName = this._nameForServiceType(termsWithService.service.serviceType, parsedBaseUrl.host); + } + const summary = this._summaryForServiceType( + termsWithService.service.serviceType, + termsValues.length > 1 ? termDoc[termsLang].name : null, + ); + + rows.push( + {serviceName} + {summary} + +
+ + + ); + } + } + + // if all the documents for at least one service have been checked, we can enable + // the submit button + let enableSubmit = false; + for (const termsWithService of this.props.termsWithServices) { + let docsAgreedForService = 0; + for (const terms of Object.values(termsWithService.terms)) { + let docAgreed = false; + for (const lang of Object.keys(terms)) { + if (lang === 'version') continue; + if (this.state.agreedUrls[terms[lang].url]) { + docAgreed = true; + break; + } + } + if (docAgreed) { + ++docsAgreedForService; + } + } + if (docsAgreedForService === Object.keys(termsWithService.terms).length) { + enableSubmit = true; + break; + } + } + + return ( + +
+

{_t("To continue you need to accept the Terms of this service.")}

+ + + + + + + + + {rows} +
{_t("Service")}{_t("Summary")}{_t("Terms")}{_t("Accept")}
+
+ + +
+ ); + } +} diff --git a/src/components/views/elements/ManageIntegsButton.js b/src/components/views/elements/ManageIntegsButton.js index ef5604dba6..e470d42f25 100644 --- a/src/components/views/elements/ManageIntegsButton.js +++ b/src/components/views/elements/ManageIntegsButton.js @@ -21,6 +21,7 @@ import sdk from '../../../index'; import ScalarAuthClient from '../../../ScalarAuthClient'; import Modal from "../../../Modal"; import { _t } from '../../../languageHandler'; +import { showIntegrationsManager } from '../../../integrations/integrations'; export default class ManageIntegsButton extends React.Component { constructor(props) { @@ -30,10 +31,7 @@ export default class ManageIntegsButton extends React.Component { onManageIntegrations = (ev) => { ev.preventDefault(); - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - Modal.createDialog(IntegrationsManager, { - room: this.props.room, - }, "mx_IntegrationsManager"); + showIntegrationsManager({ room: this.props.room }); }; render() { diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 3e5528996f..2976925594 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -29,6 +29,7 @@ import { _t } from '../../../languageHandler'; import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetEchoStore from "../../../stores/WidgetEchoStore"; import AccessibleButton from '../elements/AccessibleButton'; +import { showIntegrationsManager } from '../../../integrations/integrations'; // The maximum number of widgets that can be added in a room const MAX_WIDGETS = 2; @@ -127,11 +128,10 @@ module.exports = React.createClass({ }, _launchManageIntegrations: function() { - const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager'); - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + showIntegrationsManager({ room: this.props.room, screen: 'add_integ', - }, 'mx_IntegrationsManager'); + }); }, onClickAddWidget: function(e) { diff --git a/src/components/views/settings/IntegrationsManager.js b/src/components/views/settings/IntegrationsManager.js index 754693b73e..0f42940a7d 100644 --- a/src/components/views/settings/IntegrationsManager.js +++ b/src/components/views/settings/IntegrationsManager.js @@ -24,60 +24,26 @@ import ScalarAuthClient from '../../../ScalarAuthClient'; export default class IntegrationsManager extends React.Component { static propTypes = { - // the room object where the integrations manager should be opened in - room: PropTypes.object.isRequired, + // false to display an error saying that there is no integrations manager configured + configured: PropTypes.bool.isRequired, - // the screen name to open - screen: PropTypes.string, + // false to display an error saying that we couldn't connect to the integrations manager + connected: PropTypes.bool.isRequired, - // the integration ID to open - integrationId: PropTypes.string, + // true to display a loading spinner + loading: PropTypes.bool.isRequired, + + // The source URL to load + url: PropTypes.string, // callback when the manager is dismissed onFinished: PropTypes.func.isRequired, }; - constructor(props) { - super(props); - - this.state = { - loading: true, - configured: ScalarAuthClient.isPossible(), - connected: false, // true if a `src` is set and able to be connected to - src: null, // string for where to connect to - }; - } - - componentWillMount() { - if (!this.state.configured) return; - - const scalarClient = new ScalarAuthClient(); - scalarClient.connect().then(() => { - const hasCredentials = scalarClient.hasCredentials(); - if (!hasCredentials) { - this.setState({ - connected: false, - loading: false, - }); - } else { - const src = scalarClient.getScalarInterfaceUrlForRoom( - this.props.room, - this.props.screen, - this.props.integrationId, - ); - this.setState({ - loading: false, - connected: true, - src: src, - }); - } - }).catch(err => { - console.error(err); - this.setState({ - loading: false, - connected: false, - }); - }); + static defaultProps = { + configured: true, + connected: true, + loading: false, } componentDidMount() { @@ -105,7 +71,7 @@ export default class IntegrationsManager extends React.Component { }; render() { - if (!this.state.configured) { + if (!this.props.configured) { return (

{_t("No integrations server configured")}

@@ -114,7 +80,7 @@ export default class IntegrationsManager extends React.Component { ); } - if (this.state.loading) { + if (this.props.loading) { const Spinner = sdk.getComponent("elements.Spinner"); return (
@@ -124,7 +90,7 @@ export default class IntegrationsManager extends React.Component { ); } - if (!this.state.connected) { + if (!this.props.connected) { return (

{_t("Cannot connect to integrations server")}

@@ -133,6 +99,6 @@ export default class IntegrationsManager extends React.Component { ); } - return ; + return ; } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5d100bcfb0..059ce56e85 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -93,7 +93,6 @@ "Failed to add the following rooms to %(groupId)s:": "Failed to add the following rooms to %(groupId)s:", "Unnamed Room": "Unnamed Room", "Error": "Error", - "You cannot delete this message. (%(code)s)": "You cannot delete this message. (%(code)s)", "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", "Dismiss": "Dismiss", "Riot does not have permission to send you notifications - please check your browser settings": "Riot does not have permission to send you notifications - please check your browser settings", @@ -928,6 +927,7 @@ "Saturday": "Saturday", "Today": "Today", "Yesterday": "Yesterday", + "View Source": "View Source", "Error decrypting audio": "Error decrypting audio", "Reply": "Reply", "Edit": "Edit", @@ -1127,6 +1127,7 @@ "Start chatting": "Start chatting", "Click on the button below to start chatting!": "Click on the button below to start chatting!", "Start Chatting": "Start Chatting", + "You cannot delete this message. (%(code)s)": "You cannot delete this message. (%(code)s)", "Removing…": "Removing…", "Confirm Removal": "Confirm Removal", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", @@ -1265,6 +1266,17 @@ "Missing session data": "Missing session data", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.", "Your browser likely removed this data when running low on disk space.": "Your browser likely removed this data when running low on disk space.", + "Identity Server": "Identity Server", + "Integrations Manager": "Integrations Manager", + "Find others by phone or email": "Find others by phone or email", + "Be found by phone or email": "Be found by phone or email", + "Use Bots, bridges, widgets and sticker packs": "Use Bots, bridges, widgets and sticker packs", + "Terms of Service": "Terms of Service", + "To continue you need to accept the Terms of this service.": "To continue you need to accept the Terms of this service.", + "Service": "Service", + "Summary": "Summary", + "Terms": "Terms", + "Next": "Next", "You are currently blacklisting unverified devices; to send messages to these devices you must verify them.": "You are currently blacklisting unverified devices; to send messages to these devices you must verify them.", "We recommend you go through the verification process for each device to confirm they belong to their legitimate owner, but you can resend the message without verifying if you prefer.": "We recommend you go through the verification process for each device to confirm they belong to their legitimate owner, but you can resend the message without verifying if you prefer.", "Room contains unknown devices": "Room contains unknown devices", @@ -1298,7 +1310,6 @@ "Enter Recovery Passphrase": "Enter Recovery Passphrase", "Warning: you should only set up key backup from a trusted computer.": "Warning: you should only set up key backup from a trusted computer.", "Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Access your secure message history and set up secure messaging by entering your recovery passphrase.", - "Next": "Next", "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options": "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options", "Enter Recovery Key": "Enter Recovery Key", "This looks like a valid recovery key!": "This looks like a valid recovery key!", @@ -1319,7 +1330,6 @@ "Cancel Sending": "Cancel Sending", "Forward Message": "Forward Message", "Pin Message": "Pin Message", - "View Source": "View Source", "View Decrypted Source": "View Decrypted Source", "Unhide Preview": "Unhide Preview", "Share Permalink": "Share Permalink", diff --git a/src/integrations/integrations.js b/src/integrations/integrations.js new file mode 100644 index 0000000000..670274a90b --- /dev/null +++ b/src/integrations/integrations.js @@ -0,0 +1,55 @@ +/* +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 sdk from "../index"; +import ScalarAuthClient from '../ScalarAuthClient'; +import Modal from '../Modal'; +import { TermsNotSignedError } from '../Terms'; + +export async function showIntegrationsManager(opts) { + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + + const close = Modal.createTrackedDialog( + 'Integrations Manager', '', IntegrationsManager, { loading: true }, "mx_IntegrationsManager", + ).close; + + const scalarClient = new ScalarAuthClient(); + let props; + try { + await scalarClient.connect(); + if (!scalarClient.hasCredentials()) { + props = { connected: false }; + } else { + props = { + url: scalarClient.getScalarInterfaceUrlForRoom( + opts.room, + opts.screen, + opts.integrationId, + ), + }; + } + } catch (err) { + if (err instanceof TermsNotSignedError) { + // user canceled terms dialog, so just cancel the action + close(); + return; + } + console.error(err); + props = { connected: false }; + } + close(); + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, props, "mx_IntegrationsManager"); +} diff --git a/src/languageHandler.js b/src/languageHandler.js index 267d62a7bb..c1a426383b 100644 --- a/src/languageHandler.js +++ b/src/languageHandler.js @@ -1,6 +1,7 @@ /* Copyright 2017 MTRNord and Cooperative EITA Copyright 2017 Vector Creations Ltd. +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. @@ -353,6 +354,40 @@ export function getCurrentLanguage() { return counterpart.getLocale(); } +/** + * Given a list of language codes, pick the most appropriate one + * given the current language (ie. getCurrentLanguage()) + * English is assumed to be a reasonable default. + * + * @param {string[]} langs List of language codes to pick from + * @returns {string} The most appropriate language code from langs + */ +export function pickBestLanguage(langs) { + const currentLang = getCurrentLanguage(); + const normalisedLangs = langs.map(normalizeLanguageKey); + + { + // Best is an exact match + const currentLangIndex = normalisedLangs.indexOf(currentLang); + if (currentLangIndex > -1) return langs[currentLangIndex]; + } + + { + // Failing that, a different dialect of the same lnguage + const closeLangIndex = normalisedLangs.find((l) => l.substr(0,2) === currentLang.substr(0,2)); + if (closeLangIndex > -1) return langs[closeLangIndex]; + } + + { + // Neither of those? Try an english variant. + const enIndex = normalisedLangs.find((l) => l.startsWith('en')); + if (enIndex > -1) return langs[enIndex]; + } + + // if nothing else, use the first + return langs[0]; +} + function getLangsJson() { return new Promise((resolve, reject) => { let url; From d4af8d4993d305b34b5eef8cff05516f5e3a24b0 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Jul 2019 18:56:39 +0100 Subject: [PATCH 02/35] Use showIntegrationsManager in other places --- src/FromWidgetPostMessageApi.js | 7 +++---- src/components/views/elements/AppTile.js | 7 +++---- src/components/views/rooms/Stickerpicker.js | 8 +++----- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 79e5206f50..02c5112d81 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -23,6 +23,7 @@ import sdk from "./index"; import Modal from "./Modal"; import MatrixClientPeg from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; +import { showIntegrationsManager } from './integrations/integrations'; const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -193,13 +194,11 @@ export default class FromWidgetPostMessageApi { const integType = (data && data.integType) ? data.integType : null; const integId = (data && data.integId) ? data.integId : null; - // The dialog will take care of scalar auth for us - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + showIntegrationsManager({ room: MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), screen: 'type_' + integType, integrationId: integId, - }, "mx_IntegrationsManager"); + }); } else if (action === 'set_always_on_screen') { // This is a new message: there is no reason to support the deprecated widgetData here const data = event.data.data; diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 034a3318a5..7df01ff599 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -35,6 +35,7 @@ import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; +import { showIntegrationsManager } from '../../../integrations/integrations'; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ENABLE_REACT_PERF = false; @@ -240,13 +241,11 @@ export default class AppTile extends React.Component { if (this.props.onEditClick) { this.props.onEditClick(); } else { - // The dialog handles scalar auth for us - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + showIntegrationsManager({ room: this.props.room, screen: 'type_' + this.props.type, integrationId: this.props.id, - }, "mx_IntegrationsManager"); + }); } } diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index 6918810842..fe1d4f0f7f 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -25,6 +25,7 @@ import AccessibleButton from '../elements/AccessibleButton'; import WidgetUtils from '../../../utils/WidgetUtils'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import PersistedElement from "../elements/PersistedElement"; +import { showIntegrationsManager } from '../../../integrations/integrations'; const widgetType = 'm.stickerpicker'; @@ -348,14 +349,11 @@ export default class Stickerpicker extends React.Component { * Launch the integrations manager on the stickers integration page */ _launchManageIntegrations() { - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - - // The integrations manager will handle scalar auth for us. - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + showIntegrationsManager({ room: this.props.room, screen: `type_${widgetType}`, integrationId: this.state.widgetId, - }, "mx_IntegrationsManager"); + }); } render() { From 83f697a9a2603204f5a314a76392e5d6737327cd Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 9 Jul 2019 19:01:22 +0100 Subject: [PATCH 03/35] lint --- src/FromWidgetPostMessageApi.js | 2 -- src/components/views/dialogs/TermsDialog.js | 1 - src/components/views/elements/ManageIntegsButton.js | 1 - src/components/views/rooms/Stickerpicker.js | 1 - src/components/views/settings/IntegrationsManager.js | 1 - 5 files changed, 6 deletions(-) diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 02c5112d81..2d7d860989 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -19,8 +19,6 @@ import URL from 'url'; import dis from './dispatcher'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import sdk from "./index"; -import Modal from "./Modal"; import MatrixClientPeg from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; import { showIntegrationsManager } from './integrations/integrations'; diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index de93957603..e01313e6c4 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -108,7 +108,6 @@ export default class TermsDialog extends React.Component { render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton'); const rows = []; for (const termsWithService of this.props.termsWithServices) { diff --git a/src/components/views/elements/ManageIntegsButton.js b/src/components/views/elements/ManageIntegsButton.js index e470d42f25..f5b6d75d6c 100644 --- a/src/components/views/elements/ManageIntegsButton.js +++ b/src/components/views/elements/ManageIntegsButton.js @@ -19,7 +19,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import ScalarAuthClient from '../../../ScalarAuthClient'; -import Modal from "../../../Modal"; import { _t } from '../../../languageHandler'; import { showIntegrationsManager } from '../../../integrations/integrations'; diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index fe1d4f0f7f..6c48351992 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -17,7 +17,6 @@ import React from 'react'; import {_t, _td} from '../../../languageHandler'; import AppTile from '../elements/AppTile'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import Modal from '../../../Modal'; import sdk from '../../../index'; import ScalarAuthClient from '../../../ScalarAuthClient'; import dis from '../../../dispatcher'; diff --git a/src/components/views/settings/IntegrationsManager.js b/src/components/views/settings/IntegrationsManager.js index 0f42940a7d..149d66eef6 100644 --- a/src/components/views/settings/IntegrationsManager.js +++ b/src/components/views/settings/IntegrationsManager.js @@ -20,7 +20,6 @@ import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher'; -import ScalarAuthClient from '../../../ScalarAuthClient'; export default class IntegrationsManager extends React.Component { static propTypes = { From fc706e1d47a418c9947101e1199c51eb26671f60 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 10:50:10 +0100 Subject: [PATCH 04/35] Missed a file --- src/Terms.js | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/Terms.js diff --git a/src/Terms.js b/src/Terms.js new file mode 100644 index 0000000000..77a6d9413d --- /dev/null +++ b/src/Terms.js @@ -0,0 +1,109 @@ +/* +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 Promise from 'bluebird'; + +import MatrixClientPeg from './MatrixClientPeg'; +import sdk from './'; +import Modal from './Modal'; + +export class TermsNotSignedError extends Error {} + +/** + * Class representing a service that may have terms & conditions that + * require agreement fro mthe user before the user can use that service. + */ +export class Service { + /** + * @param {MatrixClient.SERVICETYPES} serviceType The type of service + * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') + * @param {string} accessToken The user's access token for the service + */ + constructor(serviceType, baseUrl, accessToken) { + this.serviceType = serviceType; + this.baseUrl = baseUrl; + this.accessToken = accessToken; + } +} + +/** + * Present a popup to the user prompting them to agree to terms and conditions + * + * @param {Service[]} services Object with keys 'servicetype', 'baseurl', ' + * @param {function} dialogTermsInteractionCallback Function called with an array of: + * { service: {Service}, terms: {terms response from API} } + * Must return a Promise which resolves with a list of URLs of documents agreed to + * @returns {Promise} resolves when the user agreed to all necessary terms or rejects + * if they cancel. + */ +export function presentTermsForServices(services) { + return startTermsFlow(services, dialogTermsInteractionCallback); +} + +export async function startTermsFlow(services, interactionCallback) { + const termsPromises = services.map( + (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl, s.accessToken), + ); + + const terms = await Promise.all(termsPromises); + const termsAndServices = terms.map((t, i) => { return { 'service': services[i], 'terms': t.policies }; }); + + const agreedUrls = await interactionCallback(termsAndServices); + console.log("User has agreed to URLs", agreedUrls); + + const agreePromises = termsAndServices.map((termsAndService) => { + // filter the agreed URL list for ones that are actually for this service + // (one URL may be used for multiple services) + // Not a particularly efficient loop but probably fine given the numbers involved + const urlsForService = agreedUrls.filter((url) => { + for (const terms of Object.values(termsAndService.terms)) { + for (const lang of Object.keys(terms)) { + if (lang === 'version') continue; + if (terms[lang].url === url) return true; + } + } + return false; + }); + + if (urlsForService.length === 0) return Promise.resolve(); + + return MatrixClientPeg.get().agreeToTerms( + termsAndService.service.serviceType, + termsAndService.service.baseUrl, + termsAndService.service.accessToken, + urlsForService, + ); + }); + return Promise.all(agreePromises); +} + +function dialogTermsInteractionCallback(termsWithServices) { + return new Promise((resolve, reject) => { + console.log("Terms that need agreement", termsWithServices); + const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); + + Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { + termsWithServices: termsWithServices, + onFinished: (done, agreedUrls) => { + if (!done) { + reject(new TermsNotSignedError()); + return; + } + resolve(agreedUrls); + }, + }); + }); +} From 189dd4c7b1f6218ca257e1f148f9657f4bc105f5 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 12:08:26 +0100 Subject: [PATCH 05/35] SERVICE_TYPES --- src/ScalarAuthClient.js | 2 +- src/Terms.js | 2 +- src/components/views/dialogs/TermsDialog.js | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index bab6ce3f67..decd059dd3 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -99,7 +99,7 @@ class ScalarAuthClient { if (e instanceof TermsNotSignedError) { console.log("Integrations manager requires new terms to be agreed to"); return presentTermsForServices([new Service( - Matrix.SERVICETYPES.IM, + Matrix.SERVICE_TYPES.IM, SdkConfig.get().integrations_rest_url, token, )]).then(() => { diff --git a/src/Terms.js b/src/Terms.js index 77a6d9413d..3cf55df840 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -28,7 +28,7 @@ export class TermsNotSignedError extends Error {} */ export class Service { /** - * @param {MatrixClient.SERVICETYPES} serviceType The type of service + * @param {MatrixClient.SERVICE_TYPES} serviceType The type of service * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} accessToken The user's access token for the service */ diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index e01313e6c4..dc01ea9a49 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -74,16 +74,16 @@ export default class TermsDialog extends React.Component { _nameForServiceType(serviceType, host) { switch (serviceType) { - case Matrix.SERVICETYPES.IS: + case Matrix.SERVICE_TYPES.IS: return
{_t("Identity Server")}
({host})
; - case Matrix.SERVICETYPES.IM: + case Matrix.SERVICE_TYPES.IM: return
{_t("Integrations Manager")}
({host})
; } } _summaryForServiceType(serviceType, docName) { switch (serviceType) { - case Matrix.SERVICETYPES.IS: + case Matrix.SERVICE_TYPES.IS: return
{_t("Find others by phone or email")}
@@ -91,7 +91,7 @@ export default class TermsDialog extends React.Component { {docName !== null ?
: ''} {docName !== null ? '('+docName+')' : ''}
; - case Matrix.SERVICETYPES.IM: + case Matrix.SERVICE_TYPES.IM: return
{_t("Use Bots, bridges, widgets and sticker packs")} {docName !== null ?
: ''} From 8a7227f97167dfcb196cbe1d6faf6cb55560d337 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:17:15 +0100 Subject: [PATCH 06/35] Typing Co-Authored-By: J. Ryan Stinnett --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index 3cf55df840..84fc394ebb 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -24,7 +24,7 @@ export class TermsNotSignedError extends Error {} /** * Class representing a service that may have terms & conditions that - * require agreement fro mthe user before the user can use that service. + * require agreement from the user before the user can use that service. */ export class Service { /** From fcf82efc7ca11bc0c6bd70a4046f581167a0161f Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:17:42 +0100 Subject: [PATCH 07/35] Bots isn't a proper noun so no real reason for it to get a capital Co-Authored-By: J. Ryan Stinnett --- src/components/views/dialogs/TermsDialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index dc01ea9a49..14254cdf15 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -93,7 +93,7 @@ export default class TermsDialog extends React.Component {
; case Matrix.SERVICE_TYPES.IM: return
- {_t("Use Bots, bridges, widgets and sticker packs")} + {_t("Use bots, bridges, widgets and sticker packs")} {docName !== null ?
: ''} {docName !== null ? '('+docName+')' : ''}
; From be7680c0658f79d0082d0783aecc8d5808d31f6d Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:18:59 +0100 Subject: [PATCH 08/35] update i18n strings --- src/i18n/strings/en_EN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 059ce56e85..a1352ad408 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1270,7 +1270,7 @@ "Integrations Manager": "Integrations Manager", "Find others by phone or email": "Find others by phone or email", "Be found by phone or email": "Be found by phone or email", - "Use Bots, bridges, widgets and sticker packs": "Use Bots, bridges, widgets and sticker packs", + "Use bots, bridges, widgets and sticker packs": "Use bots, bridges, widgets and sticker packs", "Terms of Service": "Terms of Service", "To continue you need to accept the Terms of this service.": "To continue you need to accept the Terms of this service.", "Service": "Service", From a9619b3c7e64fb622b414842405cd88fe13b58e2 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:19:14 +0100 Subject: [PATCH 09/35] missed copyright header --- src/FromWidgetPostMessageApi.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 2d7d860989..d34e3d8ed0 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -1,6 +1,7 @@ /* Copyright 2018 New Vector Ltd Copyright 2019 Travis Ralston +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. From 4396e993bfbd9cba1aafdf13b8e293f98a5994ae Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:20:36 +0100 Subject: [PATCH 10/35] Missed accessToken Co-Authored-By: J. Ryan Stinnett --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index 84fc394ebb..ca19d4b590 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -42,7 +42,7 @@ export class Service { /** * Present a popup to the user prompting them to agree to terms and conditions * - * @param {Service[]} services Object with keys 'servicetype', 'baseurl', ' + * @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken' * @param {function} dialogTermsInteractionCallback Function called with an array of: * { service: {Service}, terms: {terms response from API} } * Must return a Promise which resolves with a list of URLs of documents agreed to From 0ec57b58e861aae1bb7c542e06d808ab68cfcf16 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:27:29 +0100 Subject: [PATCH 11/35] Make 'terms' term less overloaded Co-Authored-By: J. Ryan Stinnett --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index ca19d4b590..a726d1ecb8 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -59,7 +59,7 @@ export async function startTermsFlow(services, interactionCallback) { ); const terms = await Promise.all(termsPromises); - const termsAndServices = terms.map((t, i) => { return { 'service': services[i], 'terms': t.policies }; }); + const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); const agreedUrls = await interactionCallback(termsAndServices); console.log("User has agreed to URLs", agreedUrls); From f7750d9df0b9bdee9f9cfb3b01d3ce616b932787 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:22:50 +0100 Subject: [PATCH 12/35] right doc, wrong function --- src/Terms.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Terms.js b/src/Terms.js index a726d1ecb8..1214dadeb7 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -43,9 +43,6 @@ export class Service { * Present a popup to the user prompting them to agree to terms and conditions * * @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken' - * @param {function} dialogTermsInteractionCallback Function called with an array of: - * { service: {Service}, terms: {terms response from API} } - * Must return a Promise which resolves with a list of URLs of documents agreed to * @returns {Promise} resolves when the user agreed to all necessary terms or rejects * if they cancel. */ @@ -53,6 +50,15 @@ export function presentTermsForServices(services) { return startTermsFlow(services, dialogTermsInteractionCallback); } +/* + * Start a flow where the user is presented with terms & conditions for some services + * + * @param {function} interactionCallback Function called with an array of: + * { service: {Service}, terms: {terms response from API} } + * Must return a Promise which resolves with a list of URLs of documents agreed to + * @returns {Promise} resolves when the user agreed to all necessary terms or rejects + * if they cancel. + */ export async function startTermsFlow(services, interactionCallback) { const termsPromises = services.map( (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl, s.accessToken), From 6fafd208a8636d81fbc4098b5b7209346896956a Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:25:30 +0100 Subject: [PATCH 13/35] add sample terms response --- src/Terms.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Terms.js b/src/Terms.js index 1214dadeb7..420b622529 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -64,6 +64,25 @@ export async function startTermsFlow(services, interactionCallback) { (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl, s.accessToken), ); + /* + * a /terms response looks like: + * { + * "policies": { + * "terms_of_service": { + * "version": "2.0", + * "en": { + * "name": "Terms of Service", + * "url": "https://example.org/somewhere/terms-2.0-en.html" + * }, + * "fr": { + * "name": "Conditions d'utilisation", + * "url": "https://example.org/somewhere/terms-2.0-fr.html" + * } + * } + * } + * } + */ + const terms = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); From c2977ddd8ee18c451816421d83992533611eeed5 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:28:45 +0100 Subject: [PATCH 14/35] More de-overloading of 'terms' Co-Authored-By: J. Ryan Stinnett --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index 420b622529..c9147e19e5 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -94,7 +94,7 @@ export async function startTermsFlow(services, interactionCallback) { // (one URL may be used for multiple services) // Not a particularly efficient loop but probably fine given the numbers involved const urlsForService = agreedUrls.filter((url) => { - for (const terms of Object.values(termsAndService.terms)) { + for (const policy of Object.values(policiesAndService)) { for (const lang of Object.keys(terms)) { if (lang === 'version') continue; if (terms[lang].url === url) return true; From 994f8f849bcad656896844fafa4431f5d442d0ee Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:30:31 +0100 Subject: [PATCH 15/35] Remove redundant dict key Co-Authored-By: J. Ryan Stinnett --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index c9147e19e5..93dc94fd1f 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -121,7 +121,7 @@ function dialogTermsInteractionCallback(termsWithServices) { const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { - termsWithServices: termsWithServices, + termsWithServices, onFinished: (done, agreedUrls) => { if (!done) { reject(new TermsNotSignedError()); From 72b1ad37a2828d7d502f836092212beaceeb62d6 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:30:48 +0100 Subject: [PATCH 16/35] Remove random space Co-Authored-By: J. Ryan Stinnett --- src/components/views/dialogs/TermsDialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index 14254cdf15..a01c4ec824 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -179,7 +179,7 @@ export default class TermsDialog extends React.Component { - + From 06c0bce053164afcf00c2b53c5e7712b0f67c441 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:32:37 +0100 Subject: [PATCH 17/35] These can be pure components --- src/components/views/dialogs/TermsDialog.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index a01c4ec824..a77d4dc620 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -22,7 +22,7 @@ import { _t, pickBestLanguage } from '../../../languageHandler'; import Matrix from 'matrix-js-sdk'; -class TermsCheckbox extends React.Component { +class TermsCheckbox extends React.PureComponent { static propTypes = { onChange: PropTypes.func.isRequired, url: PropTypes.string.isRequired, @@ -41,7 +41,7 @@ class TermsCheckbox extends React.Component { } } -export default class TermsDialog extends React.Component { +export default class TermsDialog extends React.PureComponent { static propTypes = { /** * Array of TermsWithService From 8de5c348f39d7889b41bfd73e7b576cb11213b22 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 14:33:20 +0100 Subject: [PATCH 18/35] focus is a bit silly if its starts disabled --- src/components/views/dialogs/TermsDialog.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index a77d4dc620..eb0bfeb925 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -191,7 +191,6 @@ export default class TermsDialog extends React.PureComponent { hasCancel={true} onCancel={this._onCancelClick} onPrimaryButtonClick={this._onNextClick} - focus={true} primaryDisabled={!enableSubmit} /> From 0316aa11b7d8282e8eba86869228e8fb91e955cd Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 15:12:05 +0100 Subject: [PATCH 19/35] Rest of terms/policies renaming --- src/Terms.js | 22 ++++++++--------- src/components/views/dialogs/TermsDialog.js | 26 +++++++++++---------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/Terms.js b/src/Terms.js index 93dc94fd1f..15720e5873 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -86,18 +86,18 @@ export async function startTermsFlow(services, interactionCallback) { const terms = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); - const agreedUrls = await interactionCallback(termsAndServices); + const agreedUrls = await interactionCallback(policiesAndServicePairs); console.log("User has agreed to URLs", agreedUrls); - const agreePromises = termsAndServices.map((termsAndService) => { + const agreePromises = policiesAndServicePairs.map((policiesAndService) => { // filter the agreed URL list for ones that are actually for this service // (one URL may be used for multiple services) // Not a particularly efficient loop but probably fine given the numbers involved const urlsForService = agreedUrls.filter((url) => { - for (const policy of Object.values(policiesAndService)) { - for (const lang of Object.keys(terms)) { + for (const policy of Object.values(policiesAndService.policies)) { + for (const lang of Object.keys(policy)) { if (lang === 'version') continue; - if (terms[lang].url === url) return true; + if (policy[lang].url === url) return true; } } return false; @@ -106,22 +106,22 @@ export async function startTermsFlow(services, interactionCallback) { if (urlsForService.length === 0) return Promise.resolve(); return MatrixClientPeg.get().agreeToTerms( - termsAndService.service.serviceType, - termsAndService.service.baseUrl, - termsAndService.service.accessToken, + policiesAndService.service.serviceType, + policiesAndService.service.baseUrl, + policiesAndService.service.accessToken, urlsForService, ); }); return Promise.all(agreePromises); } -function dialogTermsInteractionCallback(termsWithServices) { +function dialogTermsInteractionCallback(policiesAndServicePairs) { return new Promise((resolve, reject) => { - console.log("Terms that need agreement", termsWithServices); + console.log("Terms that need agreement", policiesAndServicePairs); const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { - termsWithServices, + policiesAndServicePairs, onFinished: (done, agreedUrls) => { if (!done) { reject(new TermsNotSignedError()); diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index eb0bfeb925..b73a8cc939 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -44,9 +44,10 @@ class TermsCheckbox extends React.PureComponent { export default class TermsDialog extends React.PureComponent { static propTypes = { /** - * Array of TermsWithService + * Array of [Service, terms] pairs, where terms is the response from the + * /terms endpoint for that service */ - termsWithServices: PropTypes.arrayOf(PropTypes.object).isRequired, + policiesAndServicePairs: PropTypes.arrayOf(PropTypes.object).isRequired, /** * Called with: @@ -101,8 +102,9 @@ export default class TermsDialog extends React.PureComponent { } _onTermsCheckboxChange = (url, checked) => { - this.state.agreedUrls[url] = checked; - this.setState({agreedUrls: this.state.agreedUrls}); + this.setState({ + agreedUrls: Object.assign({}, this.state.agreedUrls, { [url]: checked }), + }); } render() { @@ -110,19 +112,19 @@ export default class TermsDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const rows = []; - for (const termsWithService of this.props.termsWithServices) { - const parsedBaseUrl = url.parse(termsWithService.service.baseUrl); + for (const policiesAndService of this.props.policiesAndServicePairs) { + const parsedBaseUrl = url.parse(policiesAndService.service.baseUrl); - const termsValues = Object.values(termsWithService.terms); + const termsValues = Object.values(policiesAndService.policies); for (let i = 0; i < termsValues.length; ++i) { const termDoc = termsValues[i]; const termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== 'version')); let serviceName; if (i === 0) { - serviceName = this._nameForServiceType(termsWithService.service.serviceType, parsedBaseUrl.host); + serviceName = this._nameForServiceType(policiesAndService.service.serviceType, parsedBaseUrl.host); } const summary = this._summaryForServiceType( - termsWithService.service.serviceType, + policiesAndService.service.serviceType, termsValues.length > 1 ? termDoc[termsLang].name : null, ); @@ -144,9 +146,9 @@ export default class TermsDialog extends React.PureComponent { // if all the documents for at least one service have been checked, we can enable // the submit button let enableSubmit = false; - for (const termsWithService of this.props.termsWithServices) { + for (const policiesAndService of this.props.policiesAndServicePairs) { let docsAgreedForService = 0; - for (const terms of Object.values(termsWithService.terms)) { + for (const terms of Object.values(policiesAndService.policies)) { let docAgreed = false; for (const lang of Object.keys(terms)) { if (lang === 'version') continue; @@ -159,7 +161,7 @@ export default class TermsDialog extends React.PureComponent { ++docsAgreedForService; } } - if (docsAgreedForService === Object.keys(termsWithService.terms).length) { + if (docsAgreedForService === Object.keys(policiesAndService.policies).length) { enableSubmit = true; break; } From f4be4ab271fa70860baa68e1e180b02f0251fa83 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 15:27:33 +0100 Subject: [PATCH 20/35] Re-add logic for if no integ url is configured --- src/integrations/integrations.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/integrations/integrations.js b/src/integrations/integrations.js index 670274a90b..1c371bd6fa 100644 --- a/src/integrations/integrations.js +++ b/src/integrations/integrations.js @@ -22,12 +22,22 @@ import { TermsNotSignedError } from '../Terms'; export async function showIntegrationsManager(opts) { const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + let props = {}; + if (ScalarAuthClient.isPossible()) { + props.loading = true; + } else { + props.configured = false; + } + const close = Modal.createTrackedDialog( - 'Integrations Manager', '', IntegrationsManager, { loading: true }, "mx_IntegrationsManager", + 'Integrations Manager', '', IntegrationsManager, props, "mx_IntegrationsManager", ).close; + if (!ScalarAuthClient.isPossible()) { + return; + } + const scalarClient = new ScalarAuthClient(); - let props; try { await scalarClient.connect(); if (!scalarClient.hasCredentials()) { From 90a0f93215e23c60591d28e67a2da74dd21c1675 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 10 Jul 2019 16:07:31 +0100 Subject: [PATCH 21/35] jsdoc-ify comment block Co-Authored-By: J. Ryan Stinnett --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index 15720e5873..6aaec43034 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -50,7 +50,7 @@ export function presentTermsForServices(services) { return startTermsFlow(services, dialogTermsInteractionCallback); } -/* +/** * Start a flow where the user is presented with terms & conditions for some services * * @param {function} interactionCallback Function called with an array of: From f77e7fc3e854cd8eafe03c18663fdaf14f3f052c Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 11 Jul 2019 10:53:45 +0100 Subject: [PATCH 22/35] Use m.accepted_terms account data To remember what policies the user has agreed to --- src/Terms.js | 58 ++++++++++++++++++--- src/components/views/dialogs/TermsDialog.js | 12 ++++- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/Terms.js b/src/Terms.js index 6aaec43034..b3e0343c77 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -53,8 +53,10 @@ export function presentTermsForServices(services) { /** * Start a flow where the user is presented with terms & conditions for some services * - * @param {function} interactionCallback Function called with an array of: - * { service: {Service}, terms: {terms response from API} } + * @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken' + * @param {function} interactionCallback Function called with: + * * an array of { service: {Service}, terms: {terms response from API} } + * * an array of URLs the user has already agreed to * Must return a Promise which resolves with a list of URLs of documents agreed to * @returns {Promise} resolves when the user agreed to all necessary terms or rejects * if they cancel. @@ -86,14 +88,57 @@ export async function startTermsFlow(services, interactionCallback) { const terms = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); - const agreedUrls = await interactionCallback(policiesAndServicePairs); - console.log("User has agreed to URLs", agreedUrls); + // fetch the set of agreed policy URLs from account data + const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms'); + let agreedUrlSet; + if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) { + agreedUrlSet = new Set(); + } else { + agreedUrlSet = new Set(currentAcceptedTerms.getContent().accepted); + } + + // remove any policies the user has already agreed to and any services where + // they've already agreed to all the policies + // NB. it could be nicer to show the user stuff they've already agreed to, + // but then they'd assume they can un-check the boxes to un-agree to a policy, + // but that is not a thing the API supports, so probably best to just show + // things they've not agreed to yet. + const unagreedPoliciesAndServicePairs = []; + for (const {service, policies} of policiesAndServicePairs) { + const unagreedPolicies = {}; + for (const [policyName, policy] of Object.entries(policies)) { + let policyAgreed = false; + for (const lang of Object.keys(policy)) { + if (lang === 'version') continue; + if (agreedUrlSet.has(policy[lang].url)) { + policyAgreed = true; + break; + } + } + if (!policyAgreed) unagreedPolicies[policyName] = policy; + } + if (Object.keys(unagreedPolicies).length > 0) { + unagreedPoliciesAndServicePairs.push({service, policies: unagreedPolicies}); + } + } + + // if there's anything left to agree to, prompt the user + if (unagreedPoliciesAndServicePairs.length > 0) { + const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, Array.from(agreedUrlSet)); + console.log("User has agreed to URLs", newlyAgreedUrls); + agreedUrlSet = new Set(newlyAgreedUrls); + } else { + console.log("User has already agreed to all required policies"); + } + + const newAcceptedTerms = { accepted: Array.from(agreedUrlSet) }; + await MatrixClientPeg.get().setAccountData('m.accepted_terms', newAcceptedTerms); const agreePromises = policiesAndServicePairs.map((policiesAndService) => { // filter the agreed URL list for ones that are actually for this service // (one URL may be used for multiple services) // Not a particularly efficient loop but probably fine given the numbers involved - const urlsForService = agreedUrls.filter((url) => { + const urlsForService = Array.from(agreedUrlSet).filter((url) => { for (const policy of Object.values(policiesAndService.policies)) { for (const lang of Object.keys(policy)) { if (lang === 'version') continue; @@ -115,13 +160,14 @@ export async function startTermsFlow(services, interactionCallback) { return Promise.all(agreePromises); } -function dialogTermsInteractionCallback(policiesAndServicePairs) { +function dialogTermsInteractionCallback(policiesAndServicePairs, agreedUrls) { return new Promise((resolve, reject) => { console.log("Terms that need agreement", policiesAndServicePairs); const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { policiesAndServicePairs, + agreedUrls, onFinished: (done, agreedUrls) => { if (!done) { reject(new TermsNotSignedError()); diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index b73a8cc939..b79d05810a 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -47,7 +47,12 @@ export default class TermsDialog extends React.PureComponent { * Array of [Service, terms] pairs, where terms is the response from the * /terms endpoint for that service */ - policiesAndServicePairs: PropTypes.arrayOf(PropTypes.object).isRequired, + policiesAndServicePairs: PropTypes.array.isRequired, + + /** + * urls that the user has already agreed to + */ + agreedUrls: PropTypes.arrayOf(PropTypes.string), /** * Called with: @@ -57,12 +62,15 @@ export default class TermsDialog extends React.PureComponent { onFinished: PropTypes.func.isRequired, } - constructor() { + constructor(props) { super(); this.state = { // url -> boolean agreedUrls: {}, }; + for (const url of props.agreedUrls) { + this.state.agreedUrls[url] = true; + } } _onCancelClick = () => { From 3ab5acde9d412fa3aef9cb60279cc2827c101b28 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 11 Jul 2019 14:32:04 +0100 Subject: [PATCH 23/35] Add unit test for terms agreement flow --- test/Terms-test.js | 195 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 test/Terms-test.js diff --git a/test/Terms-test.js b/test/Terms-test.js new file mode 100644 index 0000000000..16a4c8c1e7 --- /dev/null +++ b/test/Terms-test.js @@ -0,0 +1,195 @@ +/* +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 expect from 'expect'; + +import sinon from 'sinon'; + +import * as Matrix from 'matrix-js-sdk'; + +import { startTermsFlow, Service } from '../src/Terms'; +import { stubClient } from './test-utils'; +import MatrixClientPeg from '../src/MatrixClientPeg'; + +const POLICY_ONE = { + version: "six", + en: { + name: "The first policy", + url: "http://example.com/one", + }, +}; + +const POLICY_TWO = { + version: "IX", + en: { + name: "The second policy", + url: "http://example.com/two", + }, +}; + +const IM_SERVICE_ONE = new Service(Matrix.SERVICE_TYPES.IM, 'https://imone.test', 'a token token'); +const IM_SERVICE_TWO = new Service(Matrix.SERVICE_TYPES.IM, 'https://imtwo.test', 'a token token'); + +describe('Terms', function() { + let sandbox; + + beforeEach(function() { + sandbox = stubClient(); + }); + + afterEach(function() { + sandbox.restore(); + }); + + it('should prompt for all terms & services if no account data', async function() { + MatrixClientPeg.get().getAccountData = sinon.stub().returns(null); + MatrixClientPeg.get().getTerms = sinon.stub().returns({ + policies: { + "policy_the_first": POLICY_ONE, + }, + }); + const interactionCallback = sinon.stub().resolves([]); + await startTermsFlow([IM_SERVICE_ONE], interactionCallback); + console.log("interaction callback calls", interactionCallback.getCall(0)); + + expect(interactionCallback.calledWith([ + { + service: IM_SERVICE_ONE, + policies: { + policy_the_first: POLICY_ONE, + }, + }, + ])).toBeTruthy(); + }); + + it('should not prompt if all policies are signed in account data', async function() { + MatrixClientPeg.get().getAccountData = sinon.stub().returns({ + getContent: sinon.stub().returns({ + accepted: ["http://example.com/one"], + }), + }); + MatrixClientPeg.get().getTerms = sinon.stub().returns({ + policies: { + "policy_the_first": POLICY_ONE, + }, + }); + MatrixClientPeg.get().agreeToTerms = sinon.stub(); + + const interactionCallback = sinon.spy(); + await startTermsFlow([IM_SERVICE_ONE], interactionCallback); + console.log("agreeToTerms call", MatrixClientPeg.get().agreeToTerms.getCall(0).args); + + expect(interactionCallback.called).toBeFalsy(); + expect(MatrixClientPeg.get().agreeToTerms.calledWith( + Matrix.SERVICE_TYPES.IM, + 'https://imone.test', + 'a token token', + [ "http://example.com/one" ], + )).toBeTruthy(); + }); + + it("should prompt for only terms that aren't already signed", async function() { + MatrixClientPeg.get().getAccountData = sinon.stub().returns({ + getContent: sinon.stub().returns({ + accepted: ["http://example.com/one"], + }), + }); + MatrixClientPeg.get().getTerms = sinon.stub().returns({ + policies: { + "policy_the_first": POLICY_ONE, + "policy_the_second": POLICY_TWO, + }, + }); + MatrixClientPeg.get().agreeToTerms = sinon.stub(); + + const interactionCallback = sinon.stub().resolves(["http://example.com/one", "http://example.com/two"]); + await startTermsFlow([IM_SERVICE_ONE], interactionCallback); + console.log("interactionCallback call", interactionCallback.getCall(0).args); + console.log("agreeToTerms call", MatrixClientPeg.get().agreeToTerms.getCall(0).args); + + expect(interactionCallback.calledWith([ + { + service: IM_SERVICE_ONE, + policies: { + policy_the_second: POLICY_TWO, + }, + }, + ])).toBeTruthy(); + expect(MatrixClientPeg.get().agreeToTerms.calledWith( + Matrix.SERVICE_TYPES.IM, + 'https://imone.test', + 'a token token', + [ "http://example.com/one", "http://example.com/two" ], + )).toBeTruthy(); + }); + + it("should prompt for only services with un-agreed policies", async function() { + MatrixClientPeg.get().getAccountData = sinon.stub().returns({ + getContent: sinon.stub().returns({ + accepted: ["http://example.com/one"], + }), + }); + + MatrixClientPeg.get().getTerms = sinon.stub(); + MatrixClientPeg.get().getTerms.callsFake((serviceType, baseUrl, accessToken) => { + switch (baseUrl) { + case 'https://imone.test': + return { + policies: { + "policy_the_first": POLICY_ONE, + }, + }; + case 'https://imtwo.test': + return { + policies: { + "policy_the_second": POLICY_TWO, + }, + }; + } + }); + + MatrixClientPeg.get().agreeToTerms = sinon.stub(); + + const interactionCallback = sinon.stub().resolves(["http://example.com/one", "http://example.com/two"]); + await startTermsFlow([IM_SERVICE_ONE, IM_SERVICE_TWO], interactionCallback); + console.log("getTerms call 0", MatrixClientPeg.get().getTerms.getCall(0).args); + console.log("getTerms call 1", MatrixClientPeg.get().getTerms.getCall(1).args); + console.log("interactionCallback call", interactionCallback.getCall(0).args); + console.log("agreeToTerms call", MatrixClientPeg.get().agreeToTerms.getCall(0).args); + + expect(interactionCallback.calledWith([ + { + service: IM_SERVICE_TWO, + policies: { + policy_the_second: POLICY_TWO, + }, + }, + ])).toBeTruthy(); + expect(MatrixClientPeg.get().agreeToTerms.calledWith( + Matrix.SERVICE_TYPES.IM, + 'https://imone.test', + 'a token token', + [ "http://example.com/one" ], + )).toBeTruthy(); + expect(MatrixClientPeg.get().agreeToTerms.calledWith( + Matrix.SERVICE_TYPES.IM, + 'https://imtwo.test', + 'a token token', + [ "http://example.com/two" ], + )).toBeTruthy(); + }); +}); + From 6b815327a053dc85031f63aada308b1c4e8b8b45 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 11 Jul 2019 14:44:45 +0100 Subject: [PATCH 24/35] apparently I was doing array bracket spacing wrong --- test/Terms-test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/Terms-test.js b/test/Terms-test.js index 16a4c8c1e7..3fc7b56e42 100644 --- a/test/Terms-test.js +++ b/test/Terms-test.js @@ -97,7 +97,7 @@ describe('Terms', function() { Matrix.SERVICE_TYPES.IM, 'https://imone.test', 'a token token', - [ "http://example.com/one" ], + ["http://example.com/one"], )).toBeTruthy(); }); @@ -132,7 +132,7 @@ describe('Terms', function() { Matrix.SERVICE_TYPES.IM, 'https://imone.test', 'a token token', - [ "http://example.com/one", "http://example.com/two" ], + ["http://example.com/one", "http://example.com/two"], )).toBeTruthy(); }); @@ -182,13 +182,13 @@ describe('Terms', function() { Matrix.SERVICE_TYPES.IM, 'https://imone.test', 'a token token', - [ "http://example.com/one" ], + ["http://example.com/one"], )).toBeTruthy(); expect(MatrixClientPeg.get().agreeToTerms.calledWith( Matrix.SERVICE_TYPES.IM, 'https://imtwo.test', 'a token token', - [ "http://example.com/two" ], + ["http://example.com/two"], )).toBeTruthy(); }); }); From 18dde859af794c22e5f06fe5a987c3dfa4a41c50 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 11 Jul 2019 14:46:20 +0100 Subject: [PATCH 25/35] s/terms/policies/ Co-Authored-By: J. Ryan Stinnett --- src/components/views/dialogs/TermsDialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index b79d05810a..6c96058099 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -44,7 +44,7 @@ class TermsCheckbox extends React.PureComponent { export default class TermsDialog extends React.PureComponent { static propTypes = { /** - * Array of [Service, terms] pairs, where terms is the response from the + * Array of [Service, policies] pairs, where policies is the response from the * /terms endpoint for that service */ policiesAndServicePairs: PropTypes.array.isRequired, From 99d1ed5efedb8f5c6d7671eb31e665231c03519f Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 11 Jul 2019 14:48:18 +0100 Subject: [PATCH 26/35] s/terms/policies/ --- src/components/views/dialogs/TermsDialog.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index 6c96058099..1c683650e5 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -123,9 +123,9 @@ export default class TermsDialog extends React.PureComponent { for (const policiesAndService of this.props.policiesAndServicePairs) { const parsedBaseUrl = url.parse(policiesAndService.service.baseUrl); - const termsValues = Object.values(policiesAndService.policies); - for (let i = 0; i < termsValues.length; ++i) { - const termDoc = termsValues[i]; + const PolicyValues = Object.values(policiesAndService.policies); + for (let i = 0; i < PolicyValues.length; ++i) { + const termDoc = PolicyValues[i]; const termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== 'version')); let serviceName; if (i === 0) { @@ -133,7 +133,7 @@ export default class TermsDialog extends React.PureComponent { } const summary = this._summaryForServiceType( policiesAndService.service.serviceType, - termsValues.length > 1 ? termDoc[termsLang].name : null, + PolicyValues.length > 1 ? termDoc[termsLang].name : null, ); rows.push( From 69fa34d71f38f98c4090080f2b4427dc46dc2b80 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 11 Jul 2019 16:00:24 +0100 Subject: [PATCH 27/35] Fix ScalarAuthClient to refresh tokens if they fail Also add a test to make sure it does it --- src/ScalarAuthClient.js | 8 +++-- test/ScalarAuthClient-test.js | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 test/ScalarAuthClient-test.js diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index decd059dd3..65d033f97b 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -51,7 +51,7 @@ class ScalarAuthClient { return this.scalarToken != null; // undef or null } - // Returns a scalar_token string + // Returns a promise that resolves to a scalar_token string getScalarToken() { let token = this.scalarToken; if (!token) token = window.localStorage.getItem("mx_scalar_token"); @@ -59,7 +59,9 @@ class ScalarAuthClient { if (!token) { return this.registerForToken(); } else { - return this._checkToken(token); + return this._checkToken(token).catch(() => { + return this.registerForToken(); + }); } } @@ -105,6 +107,8 @@ class ScalarAuthClient { )]).then(() => { return token; }); + } else { + throw e; } }); } diff --git a/test/ScalarAuthClient-test.js b/test/ScalarAuthClient-test.js new file mode 100644 index 0000000000..290cbcc741 --- /dev/null +++ b/test/ScalarAuthClient-test.js @@ -0,0 +1,57 @@ +/* +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 expect from 'expect'; + +import sinon from 'sinon'; + +import ScalarAuthClient from '../src/ScalarAuthClient'; +import MatrixClientPeg from '../src/MatrixClientPeg'; +import { stubClient } from './test-utils'; + +describe('ScalarAuthClient', function() { + let clientSandbox; + let sac; + + beforeEach(function() { + sinon.stub(window.localStorage, 'getItem').withArgs('mx_scalar_token').returns('brokentoken'); + clientSandbox = stubClient(); + }); + + afterEach(function() { + clientSandbox.restore(); + sinon.restore(); + }); + + it('should request a new token if the old one fails', async function() { + const sac = new ScalarAuthClient(); + + sac._getAccountName = sinon.stub(); + sac._getAccountName.withArgs('brokentoken').rejects({ + message: "Invalid token", + }); + sac._getAccountName.withArgs('wokentoken').resolves(MatrixClientPeg.get().getUserId()); + + MatrixClientPeg.get().getOpenIdToken = sinon.stub().resolves('this is your openid token'); + + sac.exchangeForScalarToken = sinon.stub().withArgs('this is your openid token').resolves('wokentoken'); + + await sac.connect(); + + expect(sac.exchangeForScalarToken.calledWith('this is your openid token')).toBeTruthy(); + expect(sac.scalarToken).toEqual('wokentoken'); + }); +}); From e6fdff43d847d0ba679431b9e3cbb4b27b4ffd0d Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 11 Jul 2019 16:02:02 +0100 Subject: [PATCH 28/35] unsused variable --- test/ScalarAuthClient-test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/ScalarAuthClient-test.js b/test/ScalarAuthClient-test.js index 290cbcc741..7e944189a6 100644 --- a/test/ScalarAuthClient-test.js +++ b/test/ScalarAuthClient-test.js @@ -24,7 +24,6 @@ import { stubClient } from './test-utils'; describe('ScalarAuthClient', function() { let clientSandbox; - let sac; beforeEach(function() { sinon.stub(window.localStorage, 'getItem').withArgs('mx_scalar_token').returns('brokentoken'); From f13dc82d1450c42963b547130c198332b83d4b55 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 11 Jul 2019 16:28:24 +0100 Subject: [PATCH 29/35] getTerms doesn't need an access token --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index b3e0343c77..640fd52d5e 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -63,7 +63,7 @@ export function presentTermsForServices(services) { */ export async function startTermsFlow(services, interactionCallback) { const termsPromises = services.map( - (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl, s.accessToken), + (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl), ); /* From 7c43f0bcef95a139b72962842209909dd9cd5626 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 15 Jul 2019 14:05:39 +0100 Subject: [PATCH 30/35] Don't retry on terms error --- src/ScalarAuthClient.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 65d033f97b..4aafd447b1 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -59,7 +59,11 @@ class ScalarAuthClient { if (!token) { return this.registerForToken(); } else { - return this._checkToken(token).catch(() => { + return this._checkToken(token).catch((e) => { + if (e instanceof TermsNotSignedError) { + // retrying won't help this + throw e; + } return this.registerForToken(); }); } From 11ecb4ca54dfa3349f364a25bb206dff2950f87d Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 22 Jul 2019 12:23:28 +0100 Subject: [PATCH 31/35] s/terms /policies/ Co-Authored-By: J. Ryan Stinnett --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index 640fd52d5e..6e2261bd58 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -55,7 +55,7 @@ export function presentTermsForServices(services) { * * @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken' * @param {function} interactionCallback Function called with: - * * an array of { service: {Service}, terms: {terms response from API} } + * * an array of { service: {Service}, policies: {terms response from API} } * * an array of URLs the user has already agreed to * Must return a Promise which resolves with a list of URLs of documents agreed to * @returns {Promise} resolves when the user agreed to all necessary terms or rejects From b664259e2dcac09a87081193ddaf946e2da5e493 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 22 Jul 2019 12:23:55 +0100 Subject: [PATCH 32/35] more syntactic sugar Co-Authored-By: J. Ryan Stinnett --- src/Terms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terms.js b/src/Terms.js index 6e2261bd58..401123f712 100644 --- a/src/Terms.js +++ b/src/Terms.js @@ -124,7 +124,7 @@ export async function startTermsFlow(services, interactionCallback) { // if there's anything left to agree to, prompt the user if (unagreedPoliciesAndServicePairs.length > 0) { - const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, Array.from(agreedUrlSet)); + const newlyAgreedUrls = await interactionCallback(unagreedPoliciesAndServicePairs, [...agreedUrlSet]); console.log("User has agreed to URLs", newlyAgreedUrls); agreedUrlSet = new Set(newlyAgreedUrls); } else { From 84bb0eb69600f0997ce086e6e2d84077df61aada Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 22 Jul 2019 12:25:12 +0100 Subject: [PATCH 33/35] Remove random capital --- src/components/views/dialogs/TermsDialog.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js index 1c683650e5..7a8e565555 100644 --- a/src/components/views/dialogs/TermsDialog.js +++ b/src/components/views/dialogs/TermsDialog.js @@ -123,9 +123,9 @@ export default class TermsDialog extends React.PureComponent { for (const policiesAndService of this.props.policiesAndServicePairs) { const parsedBaseUrl = url.parse(policiesAndService.service.baseUrl); - const PolicyValues = Object.values(policiesAndService.policies); - for (let i = 0; i < PolicyValues.length; ++i) { - const termDoc = PolicyValues[i]; + const policyValues = Object.values(policiesAndService.policies); + for (let i = 0; i < policyValues.length; ++i) { + const termDoc = policyValues[i]; const termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== 'version')); let serviceName; if (i === 0) { @@ -133,7 +133,7 @@ export default class TermsDialog extends React.PureComponent { } const summary = this._summaryForServiceType( policiesAndService.service.serviceType, - PolicyValues.length > 1 ? termDoc[termsLang].name : null, + policyValues.length > 1 ? termDoc[termsLang].name : null, ); rows.push( From 7d7878245bb93789ece48806cbc43f2edb9480e5 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 22 Jul 2019 18:54:04 +0100 Subject: [PATCH 34/35] Strip path component from IM rest url before passing to js-sdk. We continue to use the full URL for the calls done by matrix-react-sdk, but the standard terms API called by the js-sdk lives on the standard _matrix path. This means we don't support running IMs on a non-root path, but it's the only realistic way of transitioning to _matrix paths since configs in the wild contain bits of the API path. Once we've fully transitioned to _matrix URLs, we can give people a grace period to update their configs, then use the rest url as a regular base url. --- src/ScalarAuthClient.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 4aafd447b1..54445260d1 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -15,6 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import url from 'url'; import Promise from 'bluebird'; import SettingsStore from "./settings/SettingsStore"; import { Service, presentTermsForServices, TermsNotSignedError } from './Terms'; @@ -104,9 +105,15 @@ class ScalarAuthClient { }).catch((e) => { if (e instanceof TermsNotSignedError) { console.log("Integrations manager requires new terms to be agreed to"); + // The terms endpoints are new and so live on standard _matrix prefixes, + // but IM rest urls are currently configured with paths, so remove the + // path from the base URL before passing it to the js-sdk + const parsedImRestUrl = url.parse(SdkConfig.get().integrations_rest_url); + parsedImRestUrl.path = ''; + parsedImRestUrl.pathname = ''; return presentTermsForServices([new Service( Matrix.SERVICE_TYPES.IM, - SdkConfig.get().integrations_rest_url, + parsedImRestUrl.format(), token, )]).then(() => { return token; From 1b0d8510a2ee93beddcd34c2d5770aa9fc76b1d9 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 23 Jul 2019 10:09:16 +0100 Subject: [PATCH 35/35] Add note from commit message as a comment --- src/ScalarAuthClient.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 54445260d1..1168be4c8e 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -108,6 +108,17 @@ class ScalarAuthClient { // The terms endpoints are new and so live on standard _matrix prefixes, // but IM rest urls are currently configured with paths, so remove the // path from the base URL before passing it to the js-sdk + + // We continue to use the full URL for the calls done by + // matrix-react-sdk, but the standard terms API called + // by the js-sdk lives on the standard _matrix path. This means we + // don't support running IMs on a non-root path, but it's the only + // realistic way of transitioning to _matrix paths since configs in + // the wild contain bits of the API path. + + // Once we've fully transitioned to _matrix URLs, we can give people + // a grace period to update their configs, then use the rest url as + // a regular base url. const parsedImRestUrl = url.parse(SdkConfig.get().integrations_rest_url); parsedImRestUrl.path = ''; parsedImRestUrl.pathname = '';
{_t("Service")}{_t("Summary")}{_t("Summary")} {_t("Terms")} {_t("Accept")}