diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index 85007aeecb..05cddf2c48 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -60,3 +60,14 @@ limitations under the License. .mx_InteractiveAuthEntryComponents_passwordSection { width: 300px; } + +.mx_InteractiveAuthEntryComponents_sso_buttons { + display: flex; + flex-direction: row; + justify-content: flex-end; + margin-top: 20px; + + .mx_AccessibleButton { + margin-left: 5px; + } +} diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index b87071745d..0c48a07ccf 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -36,6 +36,13 @@ limitations under the License. font-weight: 600; } +.mx_AccessibleButton_kind_primary_outline { + color: $button-primary-bg-color; + background-color: $button-secondary-bg-color; + border: 1px solid $button-primary-bg-color; + font-weight: 600; +} + .mx_AccessibleButton_kind_secondary { color: $accent-color; font-weight: 600; @@ -60,7 +67,15 @@ limitations under the License. background-color: $button-danger-bg-color; } -.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled { +.mx_AccessibleButton_kind_danger_outline { + color: $button-danger-bg-color; + background-color: $button-secondary-bg-color; + border: 1px solid $button-danger-bg-color; +} + +.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled, +mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled +{ color: $button-danger-disabled-fg-color; background-color: $button-danger-disabled-bg-color; } diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index f4adb5751f..2492bf79a0 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -1,6 +1,6 @@ /* Copyright 2017 Vector Creations Ltd. -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 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. @@ -24,6 +24,8 @@ import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryCom import * as sdk from '../../index'; +export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); + export default createReactClass({ displayName: 'InteractiveAuth', @@ -47,7 +49,7 @@ export default createReactClass({ // @param {bool} status True if the operation requiring // auth was completed sucessfully, false if canceled. // @param {object} result The result of the authenticated call - // if successful, otherwise the error object + // if successful, otherwise the error object. // @param {object} extra Additional information about the UI Auth // process: // * emailSid {string} If email auth was performed, the sid of @@ -75,6 +77,15 @@ export default createReactClass({ // is managed by some other party and should not be managed by // the component itself. continueIsManaged: PropTypes.bool, + + // Called when the stage changes, or the stage's phase changes. First + // argument is the stage, second is the phase. Some stages do not have + // phases and will be counted as 0 (numeric). + onStagePhaseChange: PropTypes.func, + + // continueText and continueKind are passed straight through to the AuthEntryComponent. + continueText: PropTypes.string, + continueKind: PropTypes.string, }, getInitialState: function() { @@ -204,6 +215,16 @@ export default createReactClass({ this._authLogic.submitAuthDict(authData); }, + _onPhaseChange: function(newPhase) { + if (this.props.onStagePhaseChange) { + this.props.onStagePhaseChange(this.state.authStage, newPhase || 0); + } + }, + + _onStageCancel: function() { + this.props.onAuthFinished(false, ERROR_USER_CANCELLED); + }, + _renderCurrentStage: function() { const stage = this.state.authStage; if (!stage) { @@ -232,6 +253,10 @@ export default createReactClass({ fail={this._onAuthStageFailed} setEmailSid={this._setEmailSid} showContinue={!this.props.continueIsManaged} + onPhaseChange={this._onPhaseChange} + continueText={this.props.continueText} + continueKind={this.props.continueKind} + onCancel={this._onStageCancel} /> ); }, diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 26a293724a..d262a5525d 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -25,6 +25,7 @@ import classnames from 'classnames'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; +import AccessibleButton from "../elements/AccessibleButton"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -59,11 +60,21 @@ import SettingsStore from "../../../settings/SettingsStore"; * session to be failed and the process to go back to the start. * setEmailSid: m.login.email.identity only: a function to be called with the * email sid after a token is requested. + * onPhaseChange: A function which is called when the stage's phase changes. If + * the stage has no phases, call this with DEFAULT_PHASE. Takes + * one argument, the phase, and is always defined/required. + * continueText: For stages which have a continue button, the text to use. + * continueKind: For stages which have a continue button, the style of button to + * use. For example, 'danger' or 'primary'. + * onCancel A function with no arguments which is called by the stage if the + * user knowingly cancelled/dismissed the authentication attempt. * * Each component may also provide the following functions (beyond the standard React ones): * focus: set the input focus appropriately in the form. */ +export const DEFAULT_PHASE = 0; + export const PasswordAuthEntry = createReactClass({ displayName: 'PasswordAuthEntry', @@ -78,6 +89,11 @@ export const PasswordAuthEntry = createReactClass({ // is the auth logic currently waiting for something to // happen? busy: PropTypes.bool, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, getInitialState: function() { @@ -175,6 +191,11 @@ export const RecaptchaAuthEntry = createReactClass({ stageParams: PropTypes.object.isRequired, errorText: PropTypes.string, busy: PropTypes.bool, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, _onCaptchaResponse: function(response) { @@ -236,6 +257,11 @@ export const TermsAuthEntry = createReactClass({ errorText: PropTypes.string, busy: PropTypes.bool, showContinue: PropTypes.bool, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, componentWillMount: function() { @@ -378,6 +404,11 @@ export const EmailIdentityAuthEntry = createReactClass({ stageState: PropTypes.object.isRequired, fail: PropTypes.func.isRequired, setEmailSid: PropTypes.func.isRequired, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, getInitialState: function() { @@ -420,6 +451,11 @@ export const MsisdnAuthEntry = createReactClass({ clientSecret: PropTypes.func, submitAuthDict: PropTypes.func.isRequired, matrixClient: PropTypes.object, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, getInitialState: function() { @@ -571,13 +607,17 @@ export class SSOAuthEntry extends React.Component { loginType: PropTypes.string.isRequired, submitAuthDict: PropTypes.func.isRequired, errorText: PropTypes.string, + onPhaseChange: PropTypes.func.isRequired, + continueText: PropTypes.string, + continueKind: PropTypes.string, + onCancel: PropTypes.func, }; static LOGIN_TYPE = "m.login.sso"; static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso"; - static STAGE_PREAUTH = 1; // button to start SSO - static STAGE_POSTAUTH = 2; // button to confirm SSO completed + static PHASE_PREAUTH = 1; // button to start SSO + static PHASE_POSTAUTH = 2; // button to confirm SSO completed constructor(props) { super(props); @@ -589,39 +629,56 @@ export class SSOAuthEntry extends React.Component { this.props.loginType, this.props.authSessionId, ), - stage: SSOAuthEntry.STAGE_PREAUTH, + phase: SSOAuthEntry.PHASE_PREAUTH, }; } - onStartAuthClick = (e) => { - e.preventDefault(); - e.stopPropagation(); + componentDidMount(): void { + this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH); + } + onStartAuthClick = () => { // Note: We don't use PlatformPeg's startSsoAuth functions because we almost // certainly will need to open the thing in a new tab to avoid loosing application // context. - window.open(e.target.href, '_blank'); - this.setState({stage: SSOAuthEntry.STAGE_POSTAUTH}); + window.open(this.state.ssoUrl, '_blank'); + this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH}); + this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH); }; - onConfirmClick = (e) => { - e.preventDefault(); - e.stopPropagation(); - + onConfirmClick = () => { this.props.submitAuthDict({}); }; render () { - if (this.state.stage === SSOAuthEntry.STAGE_PREAUTH) { - return - {_t("Single Sign On")} - ; + let continueButton = null; + const cancelButton = ( + {_t("Cancel")} + ); + if (this.state.phase === SSOAuthEntry.PHASE_PREAUTH) { + continueButton = ( + {this.props.continueText || _t("Single Sign On")} + ); } else { - return - {_t("Continue")} - ; + continueButton = ( + {this.props.continueText || _t("Confirm")} + ); } + + return
+ {cancelButton} + {continueButton} +
; } } @@ -634,6 +691,11 @@ export const FallbackAuthEntry = createReactClass({ loginType: PropTypes.string.isRequired, submitAuthDict: PropTypes.func.isRequired, errorText: PropTypes.string, + onPhaseChange: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + this.props.onPhaseChange(DEFAULT_PHASE); }, componentWillMount: function() { diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index ff9f55cb74..c070cc6589 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2020 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. @@ -23,6 +24,7 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; +import {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; export default createReactClass({ displayName: 'InteractiveAuthDialog', @@ -44,12 +46,36 @@ export default createReactClass({ onFinished: PropTypes.func.isRequired, + // Optional title and body to show when not showing a particular stage title: PropTypes.string, + body: PropTypes.string, + + // Optional title and body pairs for particular stages and phases within + // those stages. Object structure/example is: + // { + // "org.example.stage_type": { + // 1: { + // "body": "This is a body for phase 1" of org.example.stage_type, + // "title": "Title for phase 1 of org.example.stage_type" + // }, + // 2: { + // "body": "This is a body for phase 2 of org.example.stage_type", + // "title": "Title for phase 2 of org.example.stage_type" + // "continueText": "Confirm identity with Example Auth", + // "continueKind": "danger" + // } + // } + // } + aestheticsForStagePhases: PropTypes.object, }, getInitialState: function() { return { authError: null, + + // See _onUpdateStagePhase() + uiaStage: null, + uiaStagePhase: null, }; }, @@ -57,12 +83,22 @@ export default createReactClass({ if (success) { this.props.onFinished(true, result); } else { - this.setState({ - authError: result, - }); + if (result === ERROR_USER_CANCELLED) { + this.props.onFinished(false, null); + } else { + this.setState({ + authError: result, + }); + } } }, + _onUpdateStagePhase: function(newStage, newPhase) { + console.log({newStage, newPhase}); + // We copy the stage and stage phase params into state for title selection in render() + this.setState({uiaStage: newStage, uiaStagePhase: newPhase}); + }, + _onDismissClick: function() { this.props.onFinished(false); }, @@ -71,6 +107,23 @@ export default createReactClass({ const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + // Let's pick a title, body, and other params text that we'll show to the user. The order + // is most specific first, so stagePhase > our props > defaults. + + let title = this.state.authError ? 'Error' : (this.props.title || _t('Authentication')); + let body = this.state.authError ? null : this.props.body; + let continueText = null; + let continueKind = null; + if (!this.state.authError && this.props.aestheticsForStagePhases) { + if (this.props.aestheticsForStagePhases[this.state.uiaStage]) { + const aesthetics = this.props.aestheticsForStagePhases[this.state.uiaStage][this.state.uiaStagePhase]; + if (aesthetics && aesthetics.title) title = aesthetics.title; + if (aesthetics && aesthetics.body) body = aesthetics.body; + if (aesthetics && aesthetics.continueText) continueText = aesthetics.continueText; + if (aesthetics && aesthetics.continueKind) continueKind = aesthetics.continueKind; + } + } + let content; if (this.state.authError) { content = ( @@ -88,11 +141,16 @@ export default createReactClass({ } else { content = (
-
); @@ -101,7 +159,7 @@ export default createReactClass({ return ( { content } diff --git a/src/components/views/settings/DevicesPanel.js b/src/components/views/settings/DevicesPanel.js index 2120801a81..61e7724a6f 100644 --- a/src/components/views/settings/DevicesPanel.js +++ b/src/components/views/settings/DevicesPanel.js @@ -23,6 +23,7 @@ import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; +import {SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents"; export default class DevicesPanel extends React.Component { constructor(props) { @@ -123,11 +124,29 @@ export default class DevicesPanel extends React.Component { // pop up an interactive auth dialog const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("Use Single Sign On to continue"), + body: _t("Confirm deleting these sessions by using Single Sign On to prove your identity."), + continueText: _t("Single Sign On"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("Confirm deleting these sessions"), + body: _t("Click the button below to confirm deleting these sessions."), + continueText: _t("Delete sessions"), + continueKind: "danger", + }, + }; Modal.createTrackedDialog('Delete Device Dialog', '', InteractiveAuthDialog, { title: _t("Authentication"), matrixClient: MatrixClientPeg.get(), authData: error.data, makeRequest: this._makeDeleteRequest.bind(this), + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, }); }).catch((e) => { console.error("Error deleting sessions", e); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e867623cc9..ecc2698f6d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -598,6 +598,12 @@ "up to date": "up to date", "Your homeserver does not support session management.": "Your homeserver does not support session management.", "Unable to load session list": "Unable to load session list", + "Use Single Sign On to continue": "Use Single Sign On to continue", + "Confirm deleting these sessions by using Single Sign On to prove your identity.": "Confirm deleting these sessions by using Single Sign On to prove your identity.", + "Single Sign On": "Single Sign On", + "Confirm deleting these sessions": "Confirm deleting these sessions", + "Click the button below to confirm deleting these sessions.": "Click the button below to confirm deleting these sessions.", + "Delete sessions": "Delete sessions", "Authentication": "Authentication", "Delete %(count)s sessions|other": "Delete %(count)s sessions", "Delete %(count)s sessions|one": "Delete %(count)s session", @@ -1831,7 +1837,7 @@ "Please enter the code it contains:": "Please enter the code it contains:", "Code": "Code", "Submit": "Submit", - "Single Sign On": "Single Sign On", + "Confirm": "Confirm", "Start authentication": "Start authentication", "Unable to validate homeserver/identity server": "Unable to validate homeserver/identity server", "Your Modular server": "Your Modular server", @@ -1862,7 +1868,6 @@ "Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only", "Enter username": "Enter username", "Email (optional)": "Email (optional)", - "Confirm": "Confirm", "Phone (optional)": "Phone (optional)", "Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s", "Create your Matrix account on ": "Create your Matrix account on ",