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/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 79e5206f50..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. @@ -19,10 +20,9 @@ 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'; const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -193,13 +193,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/ScalarAuthClient.js b/src/ScalarAuthClient.js index e2b2bf0eb2..1168be4c8e 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. @@ -14,13 +15,17 @@ 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'; 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"; @@ -47,7 +52,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"); @@ -55,23 +60,17 @@ 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 this._checkToken(token).catch((e) => { + if (e instanceof TermsNotSignedError) { + // retrying won't help this + throw e; } - 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(); }); } } - validateToken(token) { + _getAccountName(token) { const url = SdkConfig.get().integrations_rest_url + "/account"; return new Promise(function(resolve, reject) { @@ -83,8 +82,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 +95,54 @@ 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"); + // 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 = ''; + return presentTermsForServices([new Service( + Matrix.SERVICE_TYPES.IM, + parsedImRestUrl.format(), + token, + )]).then(() => { + return token; + }); + } else { + throw e; + } + }); + } + 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/Terms.js b/src/Terms.js new file mode 100644 index 0000000000..401123f712 --- /dev/null +++ b/src/Terms.js @@ -0,0 +1,180 @@ +/* +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 from the user before the user can use that service. + */ +export class 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 + */ + 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', 'accessToken' + * @returns {Promise} resolves when the user agreed to all necessary terms or rejects + * if they cancel. + */ +export function presentTermsForServices(services) { + return startTermsFlow(services, dialogTermsInteractionCallback); +} + +/** + * Start a flow where the user is presented with terms & conditions for some services + * + * @param {Service[]} services Object with keys 'serviceType', 'baseUrl', 'accessToken' + * @param {function} interactionCallback Function called with: + * * 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 + * if they cancel. + */ +export async function startTermsFlow(services, interactionCallback) { + const termsPromises = services.map( + (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl), + ); + + /* + * 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 }; }); + + // 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, [...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 = Array.from(agreedUrlSet).filter((url) => { + for (const policy of Object.values(policiesAndService.policies)) { + for (const lang of Object.keys(policy)) { + if (lang === 'version') continue; + if (policy[lang].url === url) return true; + } + } + return false; + }); + + if (urlsForService.length === 0) return Promise.resolve(); + + return MatrixClientPeg.get().agreeToTerms( + policiesAndService.service.serviceType, + policiesAndService.service.baseUrl, + policiesAndService.service.accessToken, + urlsForService, + ); + }); + return Promise.all(agreePromises); +} + +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()); + return; + } + resolve(agreedUrls); + }, + }); + }); +} diff --git a/src/components/views/dialogs/TermsDialog.js b/src/components/views/dialogs/TermsDialog.js new file mode 100644 index 0000000000..7a8e565555 --- /dev/null +++ b/src/components/views/dialogs/TermsDialog.js @@ -0,0 +1,209 @@ +/* +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.PureComponent { + 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.PureComponent { + static propTypes = { + /** + * Array of [Service, policies] pairs, where policies is the response from the + * /terms endpoint for that service + */ + policiesAndServicePairs: PropTypes.array.isRequired, + + /** + * urls that the user has already agreed to + */ + agreedUrls: PropTypes.arrayOf(PropTypes.string), + + /** + * 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(props) { + super(); + this.state = { + // url -> boolean + agreedUrls: {}, + }; + for (const url of props.agreedUrls) { + this.state.agreedUrls[url] = true; + } + } + + _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.SERVICE_TYPES.IS: + return
{_t("To continue you need to accept the Terms of this service.")}
+ +{_t("Service")} | +{_t("Summary")} | +{_t("Terms")} | +{_t("Accept")} | +
---|