Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

ToS for ISes/IMs: prompt on use screen #3199

Merged
merged 35 commits into from
Jul 23, 2019
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
54aaaba
Initial support for ToS dialogs for IS/IM
dbkr Jul 9, 2019
d4af8d4
Use showIntegrationsManager in other places
dbkr Jul 9, 2019
83f697a
lint
dbkr Jul 9, 2019
fc706e1
Missed a file
dbkr Jul 10, 2019
189dd4c
SERVICE_TYPES
dbkr Jul 10, 2019
8a7227f
Typing
dbkr Jul 10, 2019
fcf82ef
Bots isn't a proper noun so no real reason for it to get a capital
dbkr Jul 10, 2019
be7680c
update i18n strings
dbkr Jul 10, 2019
a9619b3
missed copyright header
dbkr Jul 10, 2019
4396e99
Missed accessToken
dbkr Jul 10, 2019
0ec57b5
Make 'terms' term less overloaded
dbkr Jul 10, 2019
f7750d9
right doc, wrong function
dbkr Jul 10, 2019
6fafd20
add sample terms response
dbkr Jul 10, 2019
c2977dd
More de-overloading of 'terms'
dbkr Jul 10, 2019
994f8f8
Remove redundant dict key
dbkr Jul 10, 2019
72b1ad3
Remove random space
dbkr Jul 10, 2019
06c0bce
These can be pure components
dbkr Jul 10, 2019
8de5c34
focus is a bit silly if its starts disabled
dbkr Jul 10, 2019
0316aa1
Rest of terms/policies renaming
dbkr Jul 10, 2019
f4be4ab
Re-add logic for if no integ url is configured
dbkr Jul 10, 2019
90a0f93
jsdoc-ify comment block
dbkr Jul 10, 2019
f77e7fc
Use m.accepted_terms account data
dbkr Jul 11, 2019
3ab5acd
Add unit test for terms agreement flow
dbkr Jul 11, 2019
6b81532
apparently I was doing array bracket spacing wrong
dbkr Jul 11, 2019
18dde85
s/terms/policies/
dbkr Jul 11, 2019
99d1ed5
s/terms/policies/
dbkr Jul 11, 2019
69fa34d
Fix ScalarAuthClient to refresh tokens if they fail
dbkr Jul 11, 2019
e6fdff4
unsused variable
dbkr Jul 11, 2019
f13dc82
getTerms doesn't need an access token
dbkr Jul 11, 2019
7c43f0b
Don't retry on terms error
dbkr Jul 15, 2019
11ecb4c
s/terms /policies/
dbkr Jul 22, 2019
b664259
more syntactic sugar
dbkr Jul 22, 2019
84bb0eb
Remove random capital
dbkr Jul 22, 2019
7d78782
Strip path component from IM rest url
dbkr Jul 22, 2019
1b0d851
Add note from commit message as a comment
dbkr Jul 23, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions res/css/_components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
35 changes: 35 additions & 0 deletions res/css/views/dialogs/_TermsDialog.scss
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 4 additions & 6 deletions src/FromWidgetPostMessageApi.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';
dbkr marked this conversation as resolved.
Show resolved Hide resolved

const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
Expand Down Expand Up @@ -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;
Expand Down
59 changes: 46 additions & 13 deletions src/ScalarAuthClient.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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";

Expand All @@ -47,31 +52,25 @@ 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");

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.
dbkr marked this conversation as resolved.
Show resolved Hide resolved
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) {
Expand All @@ -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 {
Expand All @@ -94,11 +95,43 @@ 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
dbkr marked this conversation as resolved.
Show resolved Hide resolved
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;
Expand Down
180 changes: 180 additions & 0 deletions src/Terms.js
Original file line number Diff line number Diff line change
@@ -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) {
dbkr marked this conversation as resolved.
Show resolved Hide resolved
const termsPromises = services.map(
dbkr marked this conversation as resolved.
Show resolved Hide resolved
(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(
jryans marked this conversation as resolved.
Show resolved Hide resolved
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);
},
});
});
}
Loading