diff --git a/src/Signup.js b/src/Signup.js
index 18d338cc32..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) {
@@ -130,6 +133,18 @@ class Register extends Signup {
this.password = password;
const client = this._createTemporaryClient();
this.activeStage = null;
+
+ // If there hasn't been a client secret set by this point,
+ // generate one for this session. It will only be used if
+ // we do email verification, but far simpler to just make
+ // sure we have one.
+ // We re-use this same secret over multiple calls to register
+ // so that the identity server can honour the sendAttempt
+ // parameter and not re-send email unless we actually want
+ // another mail to be sent.
+ if (!this.params.clientSecret) {
+ this.params.clientSecret = client.generateClientSecret();
+ }
return this._tryRegister(client);
}
diff --git a/src/SignupStages.js b/src/SignupStages.js
index 2b0d163a08..283b11afef 100644
--- a/src/SignupStages.js
+++ b/src/SignupStages.js
@@ -53,66 +53,24 @@ class RecaptchaStage extends Stage {
constructor(matrixClient, signupInstance) {
super(RecaptchaStage.TYPE, matrixClient, signupInstance);
this.defer = q.defer(); // resolved with the captcha response
- this.publicKey = null; // from the HS
- this.divId = null; // from the UI component
}
- // called when the UI component has loaded the recaptcha
so we can
- // render to it.
+ // called when the recaptcha has been completed.
onReceiveData(data) {
- if (!data || !data.divId) {
+ if (!data || !data.response) {
return;
}
- this.divId = data.divId;
- this._attemptRender();
+ this.defer.resolve({
+ auth: {
+ type: 'm.login.recaptcha',
+ response: data.response,
+ }
+ });
}
complete() {
- var publicKey;
- var serverParams = this.signupInstance.getServerData().params;
- if (serverParams && serverParams["m.login.recaptcha"]) {
- publicKey = serverParams["m.login.recaptcha"].public_key;
- }
- if (!publicKey) {
- return q.reject({
- message: "This server has not supplied enough information for Recaptcha " +
- "authentication",
- isFatal: true
- });
- }
- this.publicKey = publicKey;
- this._attemptRender();
return this.defer.promise;
}
-
- _attemptRender() {
- if (!global.grecaptcha) {
- console.error("grecaptcha not loaded!");
- return;
- }
- if (!this.publicKey) {
- console.error("No public key for recaptcha!");
- return;
- }
- if (!this.divId) {
- console.error("No div ID specified!");
- return;
- }
- console.log("Rendering to %s", this.divId);
- var self = this;
- global.grecaptcha.render(this.divId, {
- sitekey: this.publicKey,
- callback: function(response) {
- console.log("Received captcha response");
- self.defer.resolve({
- auth: {
- type: 'm.login.recaptcha',
- response: response
- }
- });
- }
- });
- }
}
RecaptchaStage.TYPE = "m.login.recaptcha";
@@ -158,7 +116,11 @@ class EmailIdentityStage extends Stage {
return this._completeVerify();
}
- this.clientSecret = this.client.generateClientSecret();
+ this.clientSecret = this.signupInstance.params.clientSecret;
+ if (!this.clientSecret) {
+ return q.reject(new Error("No client secret specified by Signup class!"));
+ }
+
var nextLink = this.signupInstance.params.registrationUrl +
'?client_secret=' +
encodeURIComponent(this.clientSecret) +
diff --git a/src/component-index.js b/src/component-index.js
index 8454389e93..fd89a196fb 100644
--- a/src/component-index.js
+++ b/src/component-index.js
@@ -77,6 +77,8 @@ import views$dialogs$EncryptedEventDialog from './components/views/dialogs/Encry
module.exports.components['views.dialogs.EncryptedEventDialog'] = views$dialogs$EncryptedEventDialog;
import views$dialogs$ErrorDialog from './components/views/dialogs/ErrorDialog';
module.exports.components['views.dialogs.ErrorDialog'] = views$dialogs$ErrorDialog;
+import views$dialogs$InteractiveAuthDialog from './components/views/dialogs/InteractiveAuthDialog';
+module.exports.components['views.dialogs.InteractiveAuthDialog'] = views$dialogs$InteractiveAuthDialog;
import views$dialogs$LogoutPrompt from './components/views/dialogs/LogoutPrompt';
module.exports.components['views.dialogs.LogoutPrompt'] = views$dialogs$LogoutPrompt;
import views$dialogs$NeedToRegisterDialog from './components/views/dialogs/NeedToRegisterDialog';
@@ -117,6 +119,8 @@ import views$login$CasLogin from './components/views/login/CasLogin';
module.exports.components['views.login.CasLogin'] = views$login$CasLogin;
import views$login$CustomServerDialog from './components/views/login/CustomServerDialog';
module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog;
+import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents';
+module.exports.components['views.login.InteractiveAuthEntryComponents'] = views$login$InteractiveAuthEntryComponents;
import views$login$LoginFooter from './components/views/login/LoginFooter';
module.exports.components['views.login.LoginFooter'] = views$login$LoginFooter;
import views$login$LoginHeader from './components/views/login/LoginHeader';
diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js
index 0f1b6d331f..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',
@@ -228,12 +232,9 @@ module.exports = React.createClass({
});
},
- onCaptchaLoaded: function(divIdName) {
+ onCaptchaResponse: function(response) {
this.registerLogic.tellStage("m.login.recaptcha", {
- divId: divIdName
- });
- this.setState({
- busy: false // requires user input
+ response: response
});
},
@@ -267,8 +268,15 @@ module.exports = React.createClass({
);
break;
case "Register.STEP_m.login.recaptcha":
+ var publicKey;
+ var serverParams = this.registerLogic.getServerData().params;
+ if (serverParams && serverParams["m.login.recaptcha"]) {
+ publicKey = serverParams["m.login.recaptcha"].public_key;
+ }
registerStep = (
-
+
);
break;
default:
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 operation requires additional authentication.
+ {this._renderCurrentStage()}
+ {error}
+
+
+ {submitButton}
+ {cancelButton}
+
+
+ );
+ },
+});
diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js
index fdbe6f1db1..0e5922f464 100644
--- a/src/components/views/login/CaptchaForm.js
+++ b/src/components/views/login/CaptchaForm.js
@@ -26,28 +26,34 @@ module.exports = React.createClass({
displayName: 'CaptchaForm',
propTypes: {
- onCaptchaLoaded: React.PropTypes.func.isRequired // called with div id name
+ sitePublicKey: React.PropTypes.string,
+
+ // called with the captcha response
+ onCaptchaResponse: React.PropTypes.func,
},
getDefaultProps: function() {
return {
- onCaptchaLoaded: function() {
- console.error("Unhandled onCaptchaLoaded");
- }
+ onCaptchaResponse: () => {},
+ };
+ },
+
+ getInitialState: function() {
+ return {
+ errorText: null,
};
},
componentDidMount: function() {
// Just putting a script tag into the returned jsx doesn't work, annoyingly,
// so we do this instead.
- var self = this;
- if (this.refs.recaptchaContainer) {
+ if (global.grecaptcha) {
+ // already loaded
+ this._onCaptchaLoaded();
+ } else {
console.log("Loading recaptcha script...");
var scriptTag = document.createElement('script');
- window.mx_on_recaptcha_loaded = function() {
- console.log("Loaded recaptcha script.");
- self.props.onCaptchaLoaded(DIV_ID);
- };
+ window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded()};
scriptTag.setAttribute(
'src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit"
);
@@ -55,13 +61,54 @@ module.exports = React.createClass({
}
},
+ _renderRecaptcha: function(divId) {
+ if (!global.grecaptcha) {
+ console.error("grecaptcha not loaded!");
+ throw new Error("Recaptcha did not load successfully");
+ }
+
+ var publicKey = this.props.sitePublicKey;
+ if (!publicKey) {
+ console.error("No public key for recaptcha!");
+ throw new Error(
+ "This server has not supplied enough information for Recaptcha "
+ + "authentication");
+ }
+
+ console.log("Rendering to %s", divId);
+ global.grecaptcha.render(divId, {
+ sitekey: publicKey,
+ callback: this.props.onCaptchaResponse,
+ });
+ },
+
+ _onCaptchaLoaded: function() {
+ console.log("Loaded recaptcha script.");
+ try {
+ this._renderRecaptcha(DIV_ID);
+ } catch (e) {
+ this.setState({
+ errorText: e.toString(),
+ })
+ }
+ },
+
render: function() {
- // FIXME: Tight coupling with the div id and SignupStages.js
+ let error = null;
+ if (this.state.errorText) {
+ error = (
+
+ {this.state.errorText}
+
+ );
+ }
+
return (
This Home Server would like to make sure you are not a robot
+ {error}
);
}
-});
\ No newline at end of file
+});
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 (
+