Merge pull request #691 from matrix-org/dbkr/interactive_auth_nondialog

Split out InterActiveAuthDialog
This commit is contained in:
David Baker 2017-02-14 10:55:12 +00:00 committed by GitHub
commit 17b08aedfc
6 changed files with 239 additions and 183 deletions

View file

@ -31,6 +31,8 @@ import structures$CreateRoom from './components/structures/CreateRoom';
structures$CreateRoom && (module.exports.components['structures.CreateRoom'] = structures$CreateRoom); structures$CreateRoom && (module.exports.components['structures.CreateRoom'] = structures$CreateRoom);
import structures$FilePanel from './components/structures/FilePanel'; import structures$FilePanel from './components/structures/FilePanel';
structures$FilePanel && (module.exports.components['structures.FilePanel'] = structures$FilePanel); structures$FilePanel && (module.exports.components['structures.FilePanel'] = structures$FilePanel);
import structures$InteractiveAuth from './components/structures/InteractiveAuth';
structures$InteractiveAuth && (module.exports.components['structures.InteractiveAuth'] = structures$InteractiveAuth);
import structures$LoggedInView from './components/structures/LoggedInView'; import structures$LoggedInView from './components/structures/LoggedInView';
structures$LoggedInView && (module.exports.components['structures.LoggedInView'] = structures$LoggedInView); structures$LoggedInView && (module.exports.components['structures.LoggedInView'] = structures$LoggedInView);
import structures$MatrixChat from './components/structures/MatrixChat'; import structures$MatrixChat from './components/structures/MatrixChat';

View file

@ -0,0 +1,152 @@
/*
Copyright 2017 Vector Creations 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 '../views/login/InteractiveAuthEntryComponents';
export default React.createClass({
displayName: 'InteractiveAuth',
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,
// callback called when the auth process has finished
// @param {bool} status True if the operation requiring
// auth was completed sucessfully, false if canceled.
// @param result The result of the authenticated call
onFinished: React.PropTypes.func.isRequired,
},
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,
});
});
},
_setFocus: function() {
if (this.refs.stageComponent && this.refs.stageComponent.focus) {
this.refs.stageComponent.focus();
}
},
_submitAuthDict: function(authData) {
this._authLogic.submitAuthDict(authData);
},
_renderCurrentStage: function() {
const stage = this.state.authStage;
var StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent ref="stageComponent"
loginType={stage}
authSessionId={this._authLogic.getSessionId()}
stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict}
errorText={this.state.stageErrorText}
busy={this.state.busy}
/>
);
},
render: function() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{this.state.errorText}
</div>
);
}
return (
<div>
<div>
{this._renderCurrentStage()}
{error}
</div>
</div>
);
},
});

View file

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import * as KeyCode from '../../../KeyCode'; import * as KeyCode from '../../../KeyCode';
import AccessibleButton from '../elements/AccessibleButton';
/** /**
* Basic container for modal dialogs. * Basic container for modal dialogs.
@ -59,9 +60,21 @@ export default React.createClass({
} }
}, },
_onCancelClick: function(e) {
this.props.onFinished();
},
render: function() { render: function() {
return ( return (
<div onKeyDown={this._onKeyDown} className={this.props.className}> <div onKeyDown={this._onKeyDown} className={this.props.className}>
<AccessibleButton onClick={this._onCancelClick}
className="mx_Dialog_cancelButton"
>
<img
src="img/cancel.svg" width="18" height="18"
alt="Cancel" title="Cancel"
/>
</AccessibleButton>
<div className='mx_Dialog_title'> <div className='mx_Dialog_title'>
{ this.props.title } { this.props.title }
</div> </div>

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,13 +16,12 @@ limitations under the License.
*/ */
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
const InteractiveAuth = Matrix.InteractiveAuth;
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import {getEntryComponentForLoginType} from '../login/InteractiveAuthEntryComponents'; import AccessibleButton from '../elements/AccessibleButton';
export default React.createClass({ export default React.createClass({
displayName: 'InteractiveAuthDialog', displayName: 'InteractiveAuthDialog',
@ -41,168 +41,29 @@ export default React.createClass({
onFinished: React.PropTypes.func.isRequired, onFinished: React.PropTypes.func.isRequired,
title: React.PropTypes.string, title: React.PropTypes.string,
submitButtonLabel: React.PropTypes.string,
}, },
getDefaultProps: function() { getDefaultProps: function() {
return { return {
title: "Authentication", 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,
});
});
},
_onEnterPressed: function(e) {
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 (
<StageComponent ref="stageComponent"
loginType={stage}
authSessionId={this._authLogic.getSessionId()}
stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict}
setSubmitButtonEnabled={this._setSubmitButtonEnabled}
errorText={this.state.stageErrorText}
/>
);
},
render: function() { render: function() {
const Loader = sdk.getComponent("elements.Spinner"); const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{this.state.errorText}
</div>
);
}
const submitLabel = this.state.busy ? <Loader /> : this.props.submitButtonLabel;
const submitEnabled = this.state.submitButtonEnabled && !this.state.busy;
const submitButton = (
<button className="mx_Dialog_primary"
onClick={this._onSubmit}
disabled={!submitEnabled}
>
{submitLabel}
</button>
);
const cancelButton = (
<button onClick={this._onCancel}>
Cancel
</button>
);
return ( return (
<BaseDialog className="mx_InteractiveAuthDialog" <BaseDialog className="mx_InteractiveAuthDialog"
onEnterPressed={this._onEnterPressed}
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
> >
<div className="mx_Dialog_content"> <div>
<p>This operation requires additional authentication.</p> <InteractiveAuth ref={this._collectInteractiveAuth}
{this._renderCurrentStage()} authData={this.props.authData}
{error} makeRequest={this.props.makeRequest}
</div> onFinished={this.props.onFinished}
<div className="mx_Dialog_buttons"> />
{submitButton}
{cancelButton}
</div> </div>
</BaseDialog> </BaseDialog>
); );

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -20,7 +21,7 @@ import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
/* This file contains a collection of components which are used by the /* This file contains a collection of components which are used by the
* InteractiveAuthDialog to prompt the user to enter the information needed * InteractiveAuth to prompt the user to enter the information needed
* for an auth stage. (The intention is that they could also be used for other * for an auth stage. (The intention is that they could also be used for other
* components, such as the registration flow). * components, such as the registration flow).
* *
@ -32,10 +33,10 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
* stageParams: params from the server for the stage being attempted * stageParams: params from the server for the stage being attempted
* errorText: error message from a previous attempt to authenticate * errorText: error message from a previous attempt to authenticate
* submitAuthDict: a function which will be called with the new auth dict * submitAuthDict: a function which will be called with the new auth dict
* setSubmitButtonEnabled: a function which will enable/disable the 'submit' button * busy: a boolean indicating whether the auth logic is doing something
* the user needs to wait for.
* *
* Each component may also provide the following functions (beyond the standard React ones): * 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. * focus: set the input focus appropriately in the form.
*/ */
@ -48,12 +49,16 @@ export const PasswordAuthEntry = React.createClass({
propTypes: { propTypes: {
submitAuthDict: React.PropTypes.func.isRequired, submitAuthDict: React.PropTypes.func.isRequired,
setSubmitButtonEnabled: React.PropTypes.func.isRequired,
errorText: React.PropTypes.string, errorText: React.PropTypes.string,
// is the auth logic currently waiting for something to
// happen?
busy: React.PropTypes.bool,
}, },
componentWillMount: function() { getInitialState: function() {
this.props.setSubmitButtonEnabled(false); return {
passwordValid: false,
};
}, },
focus: function() { focus: function() {
@ -62,7 +67,10 @@ export const PasswordAuthEntry = React.createClass({
} }
}, },
onSubmitClick: function() { _onSubmit: function(e) {
e.preventDefault();
if (this.props.busy) return;
this.props.submitAuthDict({ this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE, type: PasswordAuthEntry.LOGIN_TYPE,
user: MatrixClientPeg.get().credentials.userId, user: MatrixClientPeg.get().credentials.userId,
@ -72,7 +80,9 @@ export const PasswordAuthEntry = React.createClass({
_onPasswordFieldChange: function(ev) { _onPasswordFieldChange: function(ev) {
// enable the submit button iff the password is non-empty // enable the submit button iff the password is non-empty
this.props.setSubmitButtonEnabled(Boolean(ev.target.value)); this.setState({
passwordValid: Boolean(this.refs.passwordField.value),
});
}, },
render: function() { render: function() {
@ -82,16 +92,34 @@ export const PasswordAuthEntry = React.createClass({
passwordBoxClass = 'error'; passwordBoxClass = 'error';
} }
let submitButtonOrSpinner;
if (this.props.busy) {
const Loader = sdk.getComponent("elements.Spinner");
submitButtonOrSpinner = <Loader />;
} else {
submitButtonOrSpinner = (
<input type="submit"
className="mx_Dialog_primary"
disabled={!this.state.passwordValid}
/>
);
}
return ( return (
<div> <div>
<p>To continue, please enter your password.</p> <p>To continue, please enter your password.</p>
<p>Password:</p> <p>Password:</p>
<form onSubmit={this._onSubmit}>
<input <input
ref="passwordField" ref="passwordField"
className={passwordBoxClass} className={passwordBoxClass}
onChange={this._onPasswordFieldChange} onChange={this._onPasswordFieldChange}
type="password" type="password"
/> />
<div className="mx_button_row">
{submitButtonOrSpinner}
</div>
</form>
<div className="error"> <div className="error">
{this.props.errorText} {this.props.errorText}
</div> </div>
@ -110,14 +138,9 @@ export const RecaptchaAuthEntry = React.createClass({
propTypes: { propTypes: {
submitAuthDict: React.PropTypes.func.isRequired, submitAuthDict: React.PropTypes.func.isRequired,
stageParams: React.PropTypes.object.isRequired, stageParams: React.PropTypes.object.isRequired,
setSubmitButtonEnabled: React.PropTypes.func.isRequired,
errorText: React.PropTypes.string, errorText: React.PropTypes.string,
}, },
componentWillMount: function() {
this.props.setSubmitButtonEnabled(false);
},
_onCaptchaResponse: function(response) { _onCaptchaResponse: function(response) {
this.props.submitAuthDict({ this.props.submitAuthDict({
type: RecaptchaAuthEntry.LOGIN_TYPE, type: RecaptchaAuthEntry.LOGIN_TYPE,
@ -148,7 +171,6 @@ export const FallbackAuthEntry = React.createClass({
authSessionId: React.PropTypes.string.isRequired, authSessionId: React.PropTypes.string.isRequired,
loginType: React.PropTypes.string.isRequired, loginType: React.PropTypes.string.isRequired,
submitAuthDict: React.PropTypes.func.isRequired, submitAuthDict: React.PropTypes.func.isRequired,
setSubmitButtonEnabled: React.PropTypes.func.isRequired,
errorText: React.PropTypes.string, errorText: React.PropTypes.string,
}, },
@ -156,7 +178,6 @@ export const FallbackAuthEntry = React.createClass({
// we have to make the user click a button, as browsers will block // we have to make the user click a button, as browsers will block
// the popup if we open it immediately. // the popup if we open it immediately.
this._popupWindow = null; this._popupWindow = null;
this.props.setSubmitButtonEnabled(true);
window.addEventListener("message", this._onReceiveMessage); window.addEventListener("message", this._onReceiveMessage);
}, },
@ -167,13 +188,12 @@ export const FallbackAuthEntry = React.createClass({
} }
}, },
onSubmitClick: function() { _onShowFallbackClick: function() {
var url = MatrixClientPeg.get().getFallbackAuthUrl( var url = MatrixClientPeg.get().getFallbackAuthUrl(
this.props.loginType, this.props.loginType,
this.props.authSessionId this.props.authSessionId
); );
this._popupWindow = window.open(url); this._popupWindow = window.open(url);
this.props.setSubmitButtonEnabled(false);
}, },
_onReceiveMessage: function(event) { _onReceiveMessage: function(event) {
@ -188,7 +208,7 @@ export const FallbackAuthEntry = React.createClass({
render: function() { render: function() {
return ( return (
<div> <div>
Click "Submit" to authenticate <a onClick={this._onShowFallbackClick}>Start authentication</a>
<div className="error"> <div className="error">
{this.props.errorText} {this.props.errorText}
</div> </div>

View file

@ -67,16 +67,24 @@ describe('InteractiveAuthDialog', function () {
onFinished={onFinished} onFinished={onFinished}
/>, parentDiv); />, parentDiv);
// at this point there should be a password box // at this point there should be a password box and a submit button
const passwordNode = ReactTestUtils.findRenderedDOMComponentWithTag( const formNode = ReactTestUtils.findRenderedDOMComponentWithTag(dlg, "form");
const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag(
dlg, "input" dlg, "input"
); );
expect(passwordNode.type).toEqual("password"); let passwordNode;
let submitNode;
for (const node of inputNodes) {
if (node.type == 'password') {
passwordNode = node;
} else if (node.type == 'submit') {
submitNode = node;
}
}
expect(passwordNode).toExist();
expect(submitNode).toExist();
// submit should be disabled // submit should be disabled
const submitNode = ReactTestUtils.findRenderedDOMComponentWithClass(
dlg, "mx_Dialog_primary"
);
expect(submitNode.disabled).toBe(true); expect(submitNode.disabled).toBe(true);
// put something in the password box, and hit enter; that should // put something in the password box, and hit enter; that should
@ -84,9 +92,7 @@ describe('InteractiveAuthDialog', function () {
passwordNode.value = "s3kr3t"; passwordNode.value = "s3kr3t";
ReactTestUtils.Simulate.change(passwordNode); ReactTestUtils.Simulate.change(passwordNode);
expect(submitNode.disabled).toBe(false); expect(submitNode.disabled).toBe(false);
ReactTestUtils.Simulate.keyDown(passwordNode, { ReactTestUtils.Simulate.submit(formNode, {});
key: "Enter", keyCode: 13, which: 13,
});
expect(doRequest.callCount).toEqual(1); expect(doRequest.callCount).toEqual(1);
expect(doRequest.calledWithExactly({ expect(doRequest.calledWithExactly({
@ -96,8 +102,10 @@ describe('InteractiveAuthDialog', function () {
user: "@user:id", user: "@user:id",
})).toBe(true); })).toBe(true);
// the submit button should now be disabled (and be a spinner) // there should now be a spinner
expect(submitNode.disabled).toBe(true); ReactTestUtils.findRenderedComponentWithType(
dlg, sdk.getComponent('elements.Spinner'),
);
// let the request complete // let the request complete
q.delay(1).then(() => { q.delay(1).then(() => {