Merge pull request #3199 from matrix-org/dbkr/terms

ToS for ISes/IMs: prompt on use screen
This commit is contained in:
David Baker 2019-07-23 10:32:11 +01:00 committed by GitHub
commit 2eb8a8879b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 876 additions and 90 deletions

View file

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

View file

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

View file

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

View file

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

180
src/Terms.js Normal file
View file

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

View file

@ -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 <input type="checkbox"
onChange={this.onChange}
checked={this.props.checked}
/>;
}
}
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 <div>{_t("Identity Server")}<br />({host})</div>;
case Matrix.SERVICE_TYPES.IM:
return <div>{_t("Integrations Manager")}<br />({host})</div>;
}
}
_summaryForServiceType(serviceType, docName) {
switch (serviceType) {
case Matrix.SERVICE_TYPES.IS:
return <div>
{_t("Find others by phone or email")}
<br />
{_t("Be found by phone or email")}
{docName !== null ? <br /> : ''}
{docName !== null ? '('+docName+')' : ''}
</div>;
case Matrix.SERVICE_TYPES.IM:
return <div>
{_t("Use bots, bridges, widgets and sticker packs")}
{docName !== null ? <br /> : ''}
{docName !== null ? '('+docName+')' : ''}
</div>;
}
}
_onTermsCheckboxChange = (url, checked) => {
this.setState({
agreedUrls: Object.assign({}, this.state.agreedUrls, { [url]: checked }),
});
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const rows = [];
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 termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== 'version'));
let serviceName;
if (i === 0) {
serviceName = this._nameForServiceType(policiesAndService.service.serviceType, parsedBaseUrl.host);
}
const summary = this._summaryForServiceType(
policiesAndService.service.serviceType,
policyValues.length > 1 ? termDoc[termsLang].name : null,
);
rows.push(<tr key={termDoc[termsLang].url}>
<td className="mx_TermsDialog_service">{serviceName}</td>
<td className="mx_TermsDialog_summary">{summary}</td>
<td><a rel="noopener" target="_blank" href={termDoc[termsLang].url}>
<div className="mx_TermsDialog_link" />
</a></td>
<td><TermsCheckbox
url={termDoc[termsLang].url}
onChange={this._onTermsCheckboxChange}
checked={Boolean(this.state.agreedUrls[termDoc[termsLang].url])}
/></td>
</tr>);
}
}
// if all the documents for at least one service have been checked, we can enable
// the submit button
let enableSubmit = false;
for (const policiesAndService of this.props.policiesAndServicePairs) {
let docsAgreedForService = 0;
for (const terms of Object.values(policiesAndService.policies)) {
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(policiesAndService.policies).length) {
enableSubmit = true;
break;
}
}
return (
<BaseDialog className='mx_TermsDialog'
fixedWidth={false}
onFinished={this._onCancelClick}
title={_t("Terms of Service")}
contentId='mx_Dialog_content'
hasCancel={false}
>
<div id='mx_Dialog_content'>
<p>{_t("To continue you need to accept the Terms of this service.")}</p>
<table className="mx_TermsDialog_termsTable"><tbody>
<tr className="mx_TermsDialog_termsTableHeader">
<th>{_t("Service")}</th>
<th>{_t("Summary")}</th>
<th>{_t("Terms")}</th>
<th>{_t("Accept")}</th>
</tr>
{rows}
</tbody></table>
</div>
<DialogButtons primaryButton={_t('Next')}
hasCancel={true}
onCancel={this._onCancelClick}
onPrimaryButtonClick={this._onNextClick}
primaryDisabled={!enableSubmit}
/>
</BaseDialog>
);
}
}

View file

@ -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;
@ -250,13 +251,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");
});
}
}

View file

@ -19,8 +19,8 @@ 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';
export default class ManageIntegsButton extends React.Component {
constructor(props) {
@ -30,10 +30,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() {

View file

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

View file

@ -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';
@ -25,6 +24,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 +348,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() {

View file

@ -20,64 +20,29 @@ 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 = {
// 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 +70,7 @@ export default class IntegrationsManager extends React.Component {
};
render() {
if (!this.state.configured) {
if (!this.props.configured) {
return (
<div className='mx_IntegrationsManager_error'>
<h3>{_t("No integrations server configured")}</h3>
@ -114,7 +79,7 @@ export default class IntegrationsManager extends React.Component {
);
}
if (this.state.loading) {
if (this.props.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return (
<div className='mx_IntegrationsManager_loading'>
@ -124,7 +89,7 @@ export default class IntegrationsManager extends React.Component {
);
}
if (!this.state.connected) {
if (!this.props.connected) {
return (
<div className='mx_IntegrationsManager_error'>
<h3>{_t("Cannot connect to integrations server")}</h3>
@ -133,6 +98,6 @@ export default class IntegrationsManager extends React.Component {
);
}
return <iframe src={this.state.src}></iframe>;
return <iframe src={this.props.url}></iframe>;
}
}

View file

@ -1265,6 +1265,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 +1309,6 @@
"Enter Recovery Passphrase": "Enter Recovery Passphrase",
"<b>Warning</b>: you should only set up key backup from a trusted computer.": "<b>Warning</b>: 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 <button1>use your recovery key</button1> or <button2>set up new recovery options</button2>": "If you've forgotten your recovery passphrase you can <button1>use your recovery key</button1> or <button2>set up new recovery options</button2>",
"Enter Recovery Key": "Enter Recovery Key",
"This looks like a valid recovery key!": "This looks like a valid recovery key!",

View file

@ -0,0 +1,65 @@
/*
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");
let props = {};
if (ScalarAuthClient.isPossible()) {
props.loading = true;
} else {
props.configured = false;
}
const close = Modal.createTrackedDialog(
'Integrations Manager', '', IntegrationsManager, props, "mx_IntegrationsManager",
).close;
if (!ScalarAuthClient.isPossible()) {
return;
}
const scalarClient = new ScalarAuthClient();
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");
}

View file

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

View file

@ -0,0 +1,56 @@
/*
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;
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');
});
});

195
test/Terms-test.js Normal file
View file

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