diff --git a/src/Signup.js b/src/Signup.js index e387513c90..a76919f34e 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -54,6 +54,9 @@ class Signup { * This exists for the lifetime of a user's attempt to register an account, * so if their registration attempt fails for whatever reason and they * try again, call register() on the same instance again. + * + * TODO: parts of this overlap heavily with InteractiveAuth in the js-sdk. It + * would be nice to make use of that rather than rolling our own version of it. */ class Register extends Signup { constructor(hsUrl, isUrl, opts) { diff --git a/src/component-index.js b/src/component-index.js index 4122d0a631..7124a5cfcf 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -51,6 +51,7 @@ module.exports.components['views.dialogs.ChatInviteDialog'] = require('./compone module.exports.components['views.dialogs.DeactivateAccountDialog'] = require('./components/views/dialogs/DeactivateAccountDialog'); module.exports.components['views.dialogs.EncryptedEventDialog'] = require('./components/views/dialogs/EncryptedEventDialog'); module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog'); +module.exports.components['views.dialogs.InteractiveAuthDialog'] = require('./components/views/dialogs/InteractiveAuthDialog'); module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt'); module.exports.components['views.dialogs.NeedToRegisterDialog'] = require('./components/views/dialogs/NeedToRegisterDialog'); module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 6d25efef6c..5071a6b4c6 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -28,6 +28,10 @@ var CaptchaForm = require("../../views/login/CaptchaForm"); var MIN_PASSWORD_LENGTH = 6; +/** + * TODO: It would be nice to make use of the InteractiveAuthEntryComponents + * here, rather than inventing our own. + */ module.exports = React.createClass({ displayName: 'Registration', diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js new file mode 100644 index 0000000000..301bba0486 --- /dev/null +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -0,0 +1,219 @@ +/* +Copyright 2016 OpenMarket Ltd + +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 Matrix from 'matrix-js-sdk'; +const InteractiveAuth = Matrix.InteractiveAuth; + +import React from 'react'; + +import sdk from '../../../index'; + +import {getEntryComponentForLoginType} from '../login/InteractiveAuthEntryComponents'; + +export default React.createClass({ + displayName: 'InteractiveAuthDialog', + + propTypes: { + // response from initial request. If not supplied, will do a request on + // mount. + authData: React.PropTypes.shape({ + flows: React.PropTypes.array, + params: React.PropTypes.object, + session: React.PropTypes.string, + }), + + // callback + makeRequest: React.PropTypes.func.isRequired, + + onFinished: React.PropTypes.func.isRequired, + + title: React.PropTypes.string, + submitButtonLabel: React.PropTypes.string, + }, + + getDefaultProps: function() { + return { + title: "Authentication", + submitButtonLabel: "Submit", + }; + }, + + getInitialState: function() { + return { + authStage: null, + busy: false, + errorText: null, + stageErrorText: null, + submitButtonEnabled: false, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + this._authLogic = new InteractiveAuth({ + authData: this.props.authData, + doRequest: this._requestCallback, + startAuthStage: this._startAuthStage, + }); + + this._authLogic.attemptAuth().then((result) => { + this.props.onFinished(true, result); + }).catch((error) => { + console.error("Error during user-interactive auth:", error); + if (this._unmounted) { + return; + } + + const msg = error.message || error.toString(); + this.setState({ + errorText: msg + }); + }).done(); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + _startAuthStage: function(stageType, error) { + this.setState({ + authStage: stageType, + errorText: error ? error.error : null, + }, this._setFocus); + }, + + _requestCallback: function(auth) { + this.setState({ + busy: true, + errorText: null, + stageErrorText: null, + }); + return this.props.makeRequest(auth).finally(() => { + if (this._unmounted) { + return; + } + this.setState({ + busy: false, + }); + }); + }, + + _onKeyDown: function(e) { + if (e.keyCode === 27) { // escape + e.stopPropagation(); + e.preventDefault(); + if (!this.state.busy) { + this._onCancel(); + } + } + else if (e.keyCode === 13) { // enter + e.stopPropagation(); + e.preventDefault(); + if (this.state.submitButtonEnabled && !this.state.busy) { + this._onSubmit(); + } + } + }, + + _onSubmit: function() { + if (this.refs.stageComponent && this.refs.stageComponent.onSubmitClick) { + this.refs.stageComponent.onSubmitClick(); + } + }, + + _setFocus: function() { + if (this.refs.stageComponent && this.refs.stageComponent.focus) { + this.refs.stageComponent.focus(); + } + }, + + _onCancel: function() { + this.props.onFinished(false); + }, + + _setSubmitButtonEnabled: function(enabled) { + this.setState({ + submitButtonEnabled: enabled, + }); + }, + + _submitAuthDict: function(authData) { + this._authLogic.submitAuthDict(authData); + }, + + _renderCurrentStage: function() { + const stage = this.state.authStage; + var StageComponent = getEntryComponentForLoginType(stage); + return ( + + ); + }, + + render: function() { + const Loader = sdk.getComponent("elements.Spinner"); + + let error = null; + if (this.state.errorText) { + error = ( +
+ {this.state.errorText} +
+ ); + } + + const submitLabel = this.state.busy ? : this.props.submitButtonLabel; + const submitEnabled = this.state.submitButtonEnabled && !this.state.busy; + + const submitButton = ( + + ); + + const cancelButton = ( + + ); + + return ( +
+
+ {this.props.title} +
+
+

This operation requires additional authentication.

+ {this._renderCurrentStage()} + {error} +
+
+ {submitButton} + {cancelButton} +
+
+ ); + }, +}); diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js new file mode 100644 index 0000000000..23e2b442ef --- /dev/null +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -0,0 +1,212 @@ +/* +Copyright 2016 OpenMarket Ltd + +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 sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; + +/* This file contains a collection of components which are used by the + * InteractiveAuthDialog to prompt the user to enter the information needed + * for an auth stage. (The intention is that they could also be used for other + * components, such as the registration flow). + * + * Call getEntryComponentForLoginType() to get a component suitable for a + * particular login type. Each component requires the same properties: + * + * loginType: the login type of the auth stage being attempted + * authSessionId: session id from the server + * stageParams: params from the server for the stage being attempted + * errorText: error message from a previous attempt to authenticate + * submitAuthDict: a function which will be called with the new auth dict + * setSubmitButtonEnabled: a function which will enable/disable the 'submit' button + * + * Each component may also provide the following functions (beyond the standard React ones): + * onSubmitClick: handle a 'submit' button click + * focus: set the input focus appropriately in the form. + */ + +export const PasswordAuthEntry = React.createClass({ + displayName: 'PasswordAuthEntry', + + statics: { + LOGIN_TYPE: "m.login.password", + }, + + propTypes: { + submitAuthDict: React.PropTypes.func.isRequired, + setSubmitButtonEnabled: React.PropTypes.func.isRequired, + errorText: React.PropTypes.string, + }, + + componentWillMount: function() { + this.props.setSubmitButtonEnabled(false); + }, + + focus: function() { + if (this.refs.passwordField) { + this.refs.passwordField.focus(); + } + }, + + onSubmitClick: function() { + this.props.submitAuthDict({ + type: PasswordAuthEntry.LOGIN_TYPE, + user: MatrixClientPeg.get().credentials.userId, + password: this.refs.passwordField.value, + }); + }, + + _onPasswordFieldChange: function (ev) { + // enable the submit button iff the password is non-empty + this.props.setSubmitButtonEnabled(Boolean(ev.target.value)); + }, + + render: function() { + let passwordBoxClass = null; + + if (this.props.errorText) { + passwordBoxClass = 'error'; + } + + return ( +
+

To continue, please enter your password.

+

Password:

+ +
+ {this.props.errorText} +
+
+ ); + }, +}); + +export const RecaptchaAuthEntry = React.createClass({ + displayName: 'RecaptchaAuthEntry', + + statics: { + LOGIN_TYPE: "m.login.recaptcha", + }, + + propTypes: { + submitAuthDict: React.PropTypes.func.isRequired, + stageParams: React.PropTypes.object.isRequired, + setSubmitButtonEnabled: React.PropTypes.func.isRequired, + errorText: React.PropTypes.string, + }, + + componentWillMount: function() { + this.props.setSubmitButtonEnabled(false); + }, + + _onCaptchaResponse: function(response) { + this.props.submitAuthDict({ + type: RecaptchaAuthEntry.LOGIN_TYPE, + response: response, + }); + }, + + render: function() { + const CaptchaForm = sdk.getComponent("views.login.CaptchaForm"); + var sitePublicKey = this.props.stageParams.public_key; + return ( +
+ +
+ {this.props.errorText} +
+
+ ); + }, +}); + +export const FallbackAuthEntry = React.createClass({ + displayName: 'FallbackAuthEntry', + + propTypes: { + authSessionId: React.PropTypes.string.isRequired, + loginType: React.PropTypes.string.isRequired, + submitAuthDict: React.PropTypes.func.isRequired, + setSubmitButtonEnabled: React.PropTypes.func.isRequired, + errorText: React.PropTypes.string, + }, + + componentWillMount: function() { + // we have to make the user click a button, as browsers will block + // the popup if we open it immediately. + this._popupWindow = null; + this.props.setSubmitButtonEnabled(true); + window.addEventListener("message", this._onReceiveMessage); + }, + + componentWillUnmount: function() { + window.removeEventListener("message", this._onReceiveMessage); + if (this._popupWindow) { + this._popupWindow.close(); + } + }, + + onSubmitClick: function() { + var url = MatrixClientPeg.get().getFallbackAuthUrl( + this.props.loginType, + this.props.authSessionId + ); + this._popupWindow = window.open(url); + this.props.setSubmitButtonEnabled(false); + }, + + _onReceiveMessage: function(event) { + if ( + event.data === "authDone" && + event.origin === MatrixClientPeg.get().getHomeserverUrl() + ) { + this.props.submitAuthDict({}); + } + }, + + render: function() { + return ( +
+ Click "Submit" to authenticate +
+ {this.props.errorText} +
+
+ ); + }, +}); + +const AuthEntryComponents = [ + PasswordAuthEntry, + RecaptchaAuthEntry, +]; + +export function getEntryComponentForLoginType(loginType) { + for (var c of AuthEntryComponents) { + if (c.LOGIN_TYPE == loginType) { + return c; + } + } + return FallbackAuthEntry; +}; diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js new file mode 100644 index 0000000000..35daace0f8 --- /dev/null +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -0,0 +1,108 @@ +/* +Copyright 2016 OpenMarket Ltd + +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 q from 'q'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import ReactTestUtils from 'react-addons-test-utils'; +import sinon from 'sinon'; + +import sdk from 'matrix-react-sdk'; +import MatrixClientPeg from 'MatrixClientPeg'; + +import * as test_utils from '../../../test-utils'; + +const InteractiveAuthDialog = sdk.getComponent( + 'views.dialogs.InteractiveAuthDialog' +); + +describe('InteractiveAuthDialog', function () { + var parentDiv; + var sandbox; + + beforeEach(function() { + test_utils.beforeEach(this); + sandbox = test_utils.stubClient(sandbox); + parentDiv = document.createElement('div'); + document.body.appendChild(parentDiv); + }); + + afterEach(function() { + ReactDOM.unmountComponentAtNode(parentDiv); + parentDiv.remove(); + sandbox.restore(); + }); + + it('Should successfully complete a password flow', function(done) { + const onFinished = sinon.spy(); + const doRequest = sinon.stub().returns(q({a:1})); + + // tell the stub matrixclient to return a real userid + var client = MatrixClientPeg.get(); + client.credentials = {userId: "@user:id"}; + + const dlg = ReactDOM.render( + , parentDiv); + + // at this point there should be a password box + const passwordNode = ReactTestUtils.findRenderedDOMComponentWithTag( + dlg, "input" + ); + expect(passwordNode.type).toEqual("password"); + + // submit should be disabled + const submitNode = ReactTestUtils.findRenderedDOMComponentWithClass( + dlg, "mx_Dialog_primary" + ); + expect(submitNode.disabled).toBe(true); + + // put something in the password box, and hit enter; that should + // trigger a request + passwordNode.value = "s3kr3t"; + ReactTestUtils.Simulate.change(passwordNode); + expect(submitNode.disabled).toBe(false); + ReactTestUtils.Simulate.keyDown(passwordNode, { + key: "Enter", keyCode: 13, which: 13, + }); + + expect(doRequest.callCount).toEqual(1); + expect(doRequest.calledWithExactly({ + session: "sess", + type: "m.login.password", + password: "s3kr3t", + user: "@user:id", + })).toBe(true); + + // the submit button should now be disabled (and be a spinner) + expect(submitNode.disabled).toBe(true); + + // let the request complete + q.delay(1).then(() => { + expect(onFinished.callCount).toEqual(1); + expect(onFinished.calledWithExactly(true, {a:1})).toBe(true); + }).done(done, done); + }); +});