diff --git a/res/css/_components.scss b/res/css/_components.scss index 579369a509..b8811c742f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -180,6 +180,7 @@ @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; +@import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_IncomingCallbox.scss"; diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss index 16467165cf..ae55733192 100644 --- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss @@ -28,3 +28,7 @@ limitations under the License. .mx_GeneralUserSettingsTab_languageInput { @mixin mx_Settings_fullWidthField; } + +.mx_GeneralUserSettingsTab_warningIcon { + vertical-align: middle; +} diff --git a/res/css/views/terms/_InlineTermsAgreement.scss b/res/css/views/terms/_InlineTermsAgreement.scss new file mode 100644 index 0000000000..e00dcf31d1 --- /dev/null +++ b/res/css/views/terms/_InlineTermsAgreement.scss @@ -0,0 +1,45 @@ +/* +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_InlineTermsAgreement_cbContainer { + margin-bottom: 10px; + font-size: 14px; + + a { + color: $accent-color; + text-decoration: none; + } + + .mx_InlineTermsAgreement_checkbox { + margin-top: 10px; + + input { + vertical-align: text-bottom; + } + } +} + +.mx_InlineTermsAgreement_link { + display: inline-block; + mask-image: url('$(res)/img/external-link.svg'); + background-color: $accent-color; + mask-repeat: no-repeat; + mask-size: contain; + width: 12px; + height: 12px; + margin-left: 3px; + vertical-align: middle; +} diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index d3b4d8a6de..075ae93709 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -65,7 +65,7 @@ export default class IdentityAuthClient { } // Returns a promise that resolves to the access_token string from the IS - async getAccessToken() { + async getAccessToken(check=true) { if (!this.authEnabled) { // The current IS doesn't support authentication return null; @@ -77,7 +77,7 @@ export default class IdentityAuthClient { } if (!token) { - token = await this.registerForToken(); + token = await this.registerForToken(check); if (token) { this.accessToken = token; this._writeToken(); @@ -85,18 +85,20 @@ export default class IdentityAuthClient { return token; } - try { - await this._checkToken(token); - } catch (e) { - if (e instanceof TermsNotSignedError) { - // Retrying won't help this - throw e; - } - // Retry in case token expired - token = await this.registerForToken(); - if (token) { - this.accessToken = token; - this._writeToken(); + if (check) { + try { + await this._checkToken(token); + } catch (e) { + if (e instanceof TermsNotSignedError) { + // Retrying won't help this + throw e; + } + // Retry in case token expired + token = await this.registerForToken(); + if (token) { + this.accessToken = token; + this._writeToken(); + } } } @@ -126,12 +128,12 @@ export default class IdentityAuthClient { // See also https://github.com/vector-im/riot-web/issues/10455. } - async registerForToken() { + async registerForToken(check=true) { try { const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); const { access_token: identityAccessToken } = await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); - await this._checkToken(identityAccessToken); + if (check) await this._checkToken(identityAccessToken); return identityAccessToken; } catch (e) { if (e.cors === "rejected" || e.httpStatus === 404) { diff --git a/src/components/views/settings/SetIdServer.js b/src/components/views/settings/SetIdServer.js index 67387a7c8e..e7aa22527d 100644 --- a/src/components/views/settings/SetIdServer.js +++ b/src/components/views/settings/SetIdServer.js @@ -25,38 +25,7 @@ import dis from "../../../dispatcher"; import { getThreepidBindStatus } from '../../../boundThreepids'; import IdentityAuthClient from "../../../IdentityAuthClient"; import {SERVICE_TYPES} from "matrix-js-sdk"; - -/** - * If a url has no path component, etc. abbreviate it to just the hostname - * - * @param {string} u The url to be abbreviated - * @returns {string} The abbreviated url - */ -function abbreviateUrl(u) { - if (!u) return ''; - - const parsedUrl = url.parse(u); - // if it's something we can't parse as a url then just return it - if (!parsedUrl) return u; - - if (parsedUrl.path == '/') { - // we ignore query / hash parts: these aren't relevant for IS server URLs - return parsedUrl.host; - } - - return u; -} - -function unabbreviateUrl(u) { - if (!u) return ''; - - let longUrl = u; - if (!u.startsWith('https://')) longUrl = 'https://' + u; - const parsed = url.parse(longUrl); - if (parsed.hostname === null) return u; - - return longUrl; -} +import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils"; /** * Check an IS URL is valid, including liveness check diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js index 7a8d123fcd..e37fa003f7 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js @@ -33,6 +33,10 @@ import MatrixClientPeg from "../../../../../MatrixClientPeg"; import sdk from "../../../../.."; import Modal from "../../../../../Modal"; import dis from "../../../../../dispatcher"; +import {Service, startTermsFlow} from "../../../../../Terms"; +import {SERVICE_TYPES} from "matrix-js-sdk"; +import IdentityAuthClient from "../../../../../IdentityAuthClient"; +import {abbreviateUrl} from "../../../../../utils/UrlUtils"; export default class GeneralUserSettingsTab extends React.Component { static propTypes = { @@ -47,6 +51,13 @@ export default class GeneralUserSettingsTab extends React.Component { theme: SettingsStore.getValueAt(SettingLevel.ACCOUNT, "theme"), haveIdServer: Boolean(MatrixClientPeg.get().getIdentityServerUrl()), serverRequiresIdServer: null, + idServerHasUnsignedTerms: false, + requiredPolicyInfo: { // This object is passed along to a component for handling + hasTerms: false, + // policiesAndServices, // From the startTermsFlow callback + // agreedUrls, // From the startTermsFlow callback + // resolve, // Promise resolve function for startTermsFlow callback + }, }; this.dispatcherRef = dis.register(this._onAction); @@ -55,6 +66,9 @@ export default class GeneralUserSettingsTab extends React.Component { async componentWillMount() { const serverRequiresIdServer = await MatrixClientPeg.get().doesServerRequireIdServerParam(); this.setState({serverRequiresIdServer}); + + // Check to see if terms need accepting + this._checkTerms(); } componentWillUnmount() { @@ -64,9 +78,48 @@ export default class GeneralUserSettingsTab extends React.Component { _onAction = (payload) => { if (payload.action === 'id_server_changed') { this.setState({haveIdServer: Boolean(MatrixClientPeg.get().getIdentityServerUrl())}); + this._checkTerms(); } }; + async _checkTerms() { + if (!this.state.haveIdServer) { + this.setState({idServerHasUnsignedTerms: false}); + return; + } + + // By starting the terms flow we get the logic for checking which terms the user has signed + // for free. So we might as well use that for our own purposes. + const authClient = new IdentityAuthClient(); + console.log("Getting access token..."); + const idAccessToken = await authClient.getAccessToken(/*check=*/false); + console.log("Got access token: " + idAccessToken); + startTermsFlow([new Service( + SERVICE_TYPES.IS, + MatrixClientPeg.get().getIdentityServerUrl(), + idAccessToken, + )], (policiesAndServices, agreedUrls, extraClassNames) => { + return new Promise((resolve, reject) => { + this.setState({ + idServerName: abbreviateUrl(MatrixClientPeg.get().getIdentityServerUrl()), + requiredPolicyInfo: { + hasTerms: true, + policiesAndServices, + agreedUrls, + resolve, + }, + }); + }); + }).then(() => { + // User accepted all terms + this.setState({ + requiredPolicyInfo: { + hasTerms: false, + }, + }); + }); + } + _onLanguageChange = (newLanguage) => { if (this.state.language === newLanguage) return; @@ -198,6 +251,23 @@ export default class GeneralUserSettingsTab extends React.Component { } _renderDiscoverySection() { + if (this.state.requiredPolicyInfo.hasTerms) { + const InlineTermsAgreement = sdk.getComponent("views.terms.InlineTermsAgreement"); + const intro = + {_t( + "Agree to the identity server (%(serverName)s) Terms of Service to " + + "allow yourself to be discoverable by email address or phone number.", + {serverName: this.state.idServerName}, + )} + ; + return ; + } + const EmailAddresses = sdk.getComponent("views.settings.discovery.EmailAddresses"); const PhoneNumbers = sdk.getComponent("views.settings.discovery.PhoneNumbers"); const SetIdServer = sdk.getComponent("views.settings.SetIdServer"); @@ -246,6 +316,12 @@ export default class GeneralUserSettingsTab extends React.Component { } render() { + const discoWarning = this.state.requiredPolicyInfo.hasTerms + ? {_t("Warning")} + : null; + return (
{_t("General")}
@@ -253,7 +329,7 @@ export default class GeneralUserSettingsTab extends React.Component { {this._renderAccountSection()} {this._renderLanguageSection()} {this._renderThemeSection()} -
{_t("Discovery")}
+
{discoWarning} {_t("Discovery")}
{this._renderDiscoverySection()} {this._renderIntegrationManagerSection() /* Has its own title */}
{_t("Deactivate account")}
diff --git a/src/components/views/terms/InlineTermsAgreement.js b/src/components/views/terms/InlineTermsAgreement.js new file mode 100644 index 0000000000..a22359933f --- /dev/null +++ b/src/components/views/terms/InlineTermsAgreement.js @@ -0,0 +1,119 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import PropTypes from "prop-types"; +import {_t, pickBestLanguage} from "../../../languageHandler"; +import sdk from "../../../.."; + +export default class InlineTermsAgreement extends React.Component { + static propTypes = { + policiesAndServicePairs: PropTypes.array.isRequired, // array of service/policy pairs + agreedUrls: PropTypes.array.isRequired, // array of URLs the user has accepted + onFinished: PropTypes.func.isRequired, // takes an argument of accepted URLs + introElement: PropTypes.node, + }; + + constructor() { + super(); + + this.state = { + policies: [], + busy: false, + }; + } + + componentDidMount() { + // Build all the terms the user needs to accept + const policies = []; // { checked, url, name } + for (const servicePolicies of this.props.policiesAndServicePairs) { + const availablePolicies = Object.values(servicePolicies.policies); + for (const policy of availablePolicies) { + const language = pickBestLanguage(Object.keys(policy).filter(p => p !== 'version')); + const renderablePolicy = { + checked: false, + url: policy[language].url, + name: policy[language].name, + }; + policies.push(renderablePolicy); + } + } + + this.setState({policies}); + } + + _togglePolicy = (index) => { + const policies = JSON.parse(JSON.stringify(this.state.policies)); // deep & cheap clone + policies[index].checked = !policies[index].checked; + this.setState({policies}); + }; + + _onContinue = () => { + const hasUnchecked = !!this.state.policies.some(p => !p.checked); + if (hasUnchecked) return; + + this.setState({busy: true}); + this.props.onFinished(this.state.policies.map(p => p.url)); + }; + + _renderCheckboxes() { + const rendered = []; + for (let i = 0; i < this.state.policies.length; i++) { + const policy = this.state.policies[i]; + const introText = _t( + "Accept to continue:", {}, { + policyLink: () => { + return ( + + {policy.name} + + + ); + }, + }, + ); + rendered.push( +
+
{introText}
+
+ this._togglePolicy(i)} checked={policy.checked} /> + {_t("Accept")} +
+
, + ); + } + return rendered; + } + + render() { + const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton"); + const hasUnchecked = !!this.state.policies.some(p => !p.checked); + + return ( +
+ {this.props.introElement} + {this._renderCheckboxes()} + + {_t("Continue")} + +
+ ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e237f0a6de..fd5e42bcb4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -457,6 +457,7 @@ "Headphones": "Headphones", "Folder": "Folder", "Pin": "Pin", + "Accept to continue:": "Accept to continue:", "Failed to upload profile picture!": "Failed to upload profile picture!", "Upload new:": "Upload new:", "No display name": "No display name", @@ -582,9 +583,11 @@ "Set a new account password...": "Set a new account password...", "Language and region": "Language and region", "Theme": "Theme", + "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.", "Account management": "Account management", "Deactivating your account is a permanent action - be careful!": "Deactivating your account is a permanent action - be careful!", "Deactivate Account": "Deactivate Account", + "Warning": "Warning", "General": "General", "Discovery": "Discovery", "Deactivate account": "Deactivate account", @@ -1049,7 +1052,6 @@ "Checking for an update...": "Checking for an update...", "No update available.": "No update available.", "Downloading update...": "Downloading update...", - "Warning": "Warning", "Unknown Address": "Unknown Address", "NOTE: Apps are not end-to-end encrypted": "NOTE: Apps are not end-to-end encrypted", "Warning: This widget might use cookies.": "Warning: This widget might use cookies.", diff --git a/src/utils/UrlUtils.js b/src/utils/UrlUtils.js new file mode 100644 index 0000000000..7b207c128e --- /dev/null +++ b/src/utils/UrlUtils.js @@ -0,0 +1,49 @@ +/* +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"; + +/** + * If a url has no path component, etc. abbreviate it to just the hostname + * + * @param {string} u The url to be abbreviated + * @returns {string} The abbreviated url + */ +export function abbreviateUrl(u) { + if (!u) return ''; + + const parsedUrl = url.parse(u); + // if it's something we can't parse as a url then just return it + if (!parsedUrl) return u; + + if (parsedUrl.path === '/') { + // we ignore query / hash parts: these aren't relevant for IS server URLs + return parsedUrl.host; + } + + return u; +} + +export function unabbreviateUrl(u) { + if (!u) return ''; + + let longUrl = u; + if (!u.startsWith('https://')) longUrl = 'https://' + u; + const parsed = url.parse(longUrl); + if (parsed.hostname === null) return u; + + return longUrl; +}