diff --git a/examples/trivial/index.js b/examples/trivial/index.js index 5752e6bd..2be90549 100644 --- a/examples/trivial/index.js +++ b/examples/trivial/index.js @@ -24,7 +24,52 @@ var React = require("react"); // maps cannot pass through two stages). var MatrixReactSdk = require("../../src/index"); -React.render( - , +// Here, we do some crude URL analysis to allow +// deep-linking. We only support registration +// deep-links in this example. +function routeUrl(location) { + if (location.hash.indexOf('#/register') == 0) { + var hashparts = location.hash.split('?'); + var params = {}; + if (hashparts.length == 2) { + var pairs = hashparts[1].split('&'); + for (var i = 0; i < pairs.length; ++i) { + var parts = pairs[i].split('='); + if (parts.length != 2) continue; + params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]); + } + } + window.matrixChat.showScreen('register', params); + } +} + +var loaded = false; + +window.onload = function() { + routeUrl(window.location); + loaded = true; +} + +// This will be called whenever the SDK changes screens, +// so a web page can update the URL bar appropriately. +var onNewScreen = function(screen) { + if (!loaded) return; + window.location.hash = '#/'+screen; +} + +// We use this to work out what URL the SDK should +// pass through when registering to allow the user to +// click back to the client having registered. +// It's up to us to recognise if we're loaded with +// this URL and tell MatrixClient to resume registration. +var makeRegistrationUrl = function() { + return window.location.protocol + '//' + + window.location.host + + window.location.pathname + + '#/register'; +} + +window.matrixChat = React.render( + , document.getElementById('matrixchat') ); diff --git a/skins/base/views/organisms/RoomView.js b/skins/base/views/organisms/RoomView.js index eb91b544..7a46f88d 100644 --- a/skins/base/views/organisms/RoomView.js +++ b/skins/base/views/organisms/RoomView.js @@ -37,6 +37,12 @@ module.exports = React.createClass({ mixins: [RoomViewController], render: function() { + if (!this.state.room) { + return ( +
+ ); + } + var myUserId = MatrixClientPeg.get().credentials.userId; if (this.state.room.currentState.members[myUserId].membership == 'invite') { if (this.state.joining) { diff --git a/skins/base/views/pages/MatrixChat.js b/skins/base/views/pages/MatrixChat.js index 11e2be9c..b9b5a1a9 100644 --- a/skins/base/views/pages/MatrixChat.js +++ b/skins/base/views/pages/MatrixChat.js @@ -23,6 +23,7 @@ var LeftPanel = ComponentBroker.get('organisms/LeftPanel'); var RoomView = ComponentBroker.get('organisms/RoomView'); var RightPanel = ComponentBroker.get('organisms/RightPanel'); var Login = ComponentBroker.get('templates/Login'); +var Register = ComponentBroker.get('templates/Register'); var MatrixChatController = require("../../../../src/controllers/pages/MatrixChat"); @@ -47,6 +48,14 @@ module.exports = React.createClass({ return ( ); + } else if (this.state.screen == 'register') { + return ( + + ); } else { return ( diff --git a/skins/base/views/templates/Login.js b/skins/base/views/templates/Login.js index 6cd721e2..ceae07ec 100644 --- a/skins/base/views/templates/Login.js +++ b/skins/base/views/templates/Login.js @@ -40,6 +40,7 @@ module.exports = React.createClass({

Please log in:

{this.componentForStep(this.state.step)}
{this.state.errorText}
+ Create a new account
); } diff --git a/skins/base/views/templates/Register.js b/skins/base/views/templates/Register.js new file mode 100644 index 00000000..676fa2e2 --- /dev/null +++ b/skins/base/views/templates/Register.js @@ -0,0 +1,55 @@ +/* +Copyright 2015 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. +*/ + +'use strict'; + +var React = require('react'); + +var ComponentBroker = require("../../../../src/ComponentBroker"); + +var Loader = require("react-loader"); + +var RegisterController = require("../../../../src/controllers/templates/Register"); + +module.exports = React.createClass({ + displayName: 'Register', + mixins: [RegisterController], + + registerContent: function() { + if (this.state.busy) { + return ( + + ); + } else { + return ( +
+

Create a new account:

+ {this.componentForStep(this.state.step)} +
{this.state.errorText}
+ Sign in with existing account +
+ ); + } + }, + + render: function() { + return ( +
+ {this.registerContent()} +
+ ); + } +}); diff --git a/src/ComponentBroker.js b/src/ComponentBroker.js index 6636ada1..32587c62 100644 --- a/src/ComponentBroker.js +++ b/src/ComponentBroker.js @@ -85,6 +85,7 @@ require('../skins/base/views/molecules/MemberTile'); require('../skins/base/views/organisms/RoomList'); require('../skins/base/views/organisms/RoomView'); require('../skins/base/views/templates/Login'); +require('../skins/base/views/templates/Register'); require('../skins/base/views/organisms/Notifier'); require('../skins/base/views/organisms/CreateRoom'); require('../skins/base/views/molecules/UserSelector'); diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index a1c820ee..0b6c2649 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -24,11 +24,13 @@ var matrixClient = null; var localStorage = window.localStorage; if (localStorage) { var hs_url = localStorage.getItem("mx_hs_url"); + var is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; var access_token = localStorage.getItem("mx_access_token"); var user_id = localStorage.getItem("mx_user_id"); if (access_token && user_id && hs_url) { matrixClient = Matrix.createClient({ baseUrl: hs_url, + idBaseUrl: is_url, accessToken: access_token, userId: user_id }); @@ -44,8 +46,11 @@ module.exports = { matrixClient = cli; }, - replaceUsingUrl: function(hs_url) { - matrixClient = Matrix.createClient(hs_url); + replaceUsingUrls: function(hs_url, is_url) { + matrixClient = Matrix.createClient({ + baseUrl: hs_url, + idBaseUrl: is_url + }); } }; diff --git a/src/controllers/organisms/RoomView.js b/src/controllers/organisms/RoomView.js index 69b7596a..cef2c3ac 100644 --- a/src/controllers/organisms/RoomView.js +++ b/src/controllers/organisms/RoomView.js @@ -36,7 +36,7 @@ var tileTypes = { module.exports = { getInitialState: function() { return { - room: MatrixClientPeg.get().getRoom(this.props.roomId), + room: this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null, messageCap: INITIAL_SIZE } }, diff --git a/src/controllers/pages/MatrixChat.js b/src/controllers/pages/MatrixChat.js index 717f91e7..44a4df07 100644 --- a/src/controllers/pages/MatrixChat.js +++ b/src/controllers/pages/MatrixChat.js @@ -44,6 +44,11 @@ module.exports = { this.focusComposer = false; document.addEventListener("keydown", this.onKeyDown); window.addEventListener("focus", this.onFocus); + if (this.state.logged_in) { + this.notifyNewScreen(''); + } else { + this.notifyNewScreen('login'); + } }, componentWillUnmount: function() { @@ -63,14 +68,45 @@ module.exports = { switch (payload.action) { case 'logout': - this.setState({ + this.replaceState({ logged_in: false, ready: false }); + localStorage.removeItem("mx_hs_url"); + localStorage.removeItem("mx_user_id"); + localStorage.removeItem("mx_access_token"); Notifier.stop(); MatrixClientPeg.get().removeAllListeners(); MatrixClientPeg.replace(null); break; + case 'start_registration': + if (this.state.logged_in) return; + var newState = payload.params || {}; + newState.screen = 'register'; + if ( + payload.params && + payload.params.client_secret && + payload.params.session_id && + payload.params.hs_url && + payload.params.is_url && + payload.params.sid + ) { + newState.register_client_secret = payload.params.client_secret; + newState.register_session_id = payload.params.session_id; + newState.register_hs_url = payload.params.hs_url; + newState.register_is_url = payload.params.is_url; + newState.register_id_sid = payload.params.sid; + } + this.replaceState(newState); + this.notifyNewScreen('register'); + break; + case 'start_login': + if (this.state.logged_in) return; + this.replaceState({ + screen: 'login' + }); + this.notifyNewScreen('login'); + break; case 'view_room': this.focusComposer = true; this.setState({ @@ -99,8 +135,12 @@ module.exports = { }, onLoggedIn: function() { - this.setState({logged_in: true}); + this.setState({ + screen: undefined, + logged_in: true + }); this.startMatrixClient(); + this.notifyNewScreen(''); }, startMatrixClient: function() { @@ -137,6 +177,26 @@ module.exports = { onFocus: function(ev) { dis.dispatch({action: 'focus_composer'}); + }, + + showScreen(screen, params) { + if (screen == 'register') { + dis.dispatch({ + action: 'start_registration', + params: params + }); + } else if (screen == 'login') { + dis.dispatch({ + action: 'start_login', + params: params + }); + } + }, + + notifyNewScreen: function(screen) { + if (this.props.onNewScreen) { + this.props.onNewScreen(screen); + } } }; diff --git a/src/controllers/templates/Login.js b/src/controllers/templates/Login.js index f3c58cf2..714fb272 100644 --- a/src/controllers/templates/Login.js +++ b/src/controllers/templates/Login.js @@ -20,6 +20,7 @@ var React = require('react'); var MatrixClientPeg = require("../../MatrixClientPeg"); var Matrix = require("matrix-js-sdk"); +var dis = require("../../dispatcher"); var ComponentBroker = require("../../ComponentBroker"); @@ -41,8 +42,14 @@ module.exports = { onHSChosen: function(ev) { ev.preventDefault(); - MatrixClientPeg.replaceUsingUrl(this.refs.serverConfig.getHsUrl()); - this.setState({hs_url: this.refs.serverConfig.getHsUrl()}); + MatrixClientPeg.replaceUsingUrls( + this.refs.serverConfig.getHsUrl(), + this.refs.serverConfig.getIsUrl() + ); + this.setState({ + hs_url: this.refs.serverConfig.getHsUrl(), + is_url: this.refs.serverConfig.getIsUrl() + }); this.setStep("fetch_stages"); var cli = MatrixClientPeg.get(); this.setState({busy: true}); @@ -71,12 +78,14 @@ module.exports = { // XXX: we assume this means we're logged in, but there could be a next stage MatrixClientPeg.replace(Matrix.createClient({ baseUrl: that.state.hs_url, + idBaseUrl: that.state.is_url, userId: data.user_id, accessToken: data.access_token })); var localStorage = window.localStorage; if (localStorage) { localStorage.setItem("mx_hs_url", that.state.hs_url); + localStorage.setItem("mx_is_url", that.state.is_url); localStorage.setItem("mx_user_id", data.user_id); localStorage.setItem("mx_access_token", data.access_token); } else { @@ -85,9 +94,6 @@ module.exports = { if (that.props.onLoggedIn) { that.props.onLoggedIn(); } - /*dis.dispatch({ - 'action': 'logged_in' - });*/ }, function(error) { that.setStep("stage_m.login.password"); that.setState({errorText: 'Login failed.'}); @@ -118,4 +124,11 @@ module.exports = { ); } }, + + showRegister: function(ev) { + ev.preventDefault(); + dis.dispatch({ + action: 'start_registration' + }); + } }; diff --git a/src/controllers/templates/Register.js b/src/controllers/templates/Register.js new file mode 100644 index 00000000..650fdd6b --- /dev/null +++ b/src/controllers/templates/Register.js @@ -0,0 +1,346 @@ +/* +Copyright 2015 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. +*/ + +'use strict'; + +var React = require('react'); + +var MatrixClientPeg = require("../../MatrixClientPeg"); +var Matrix = require("matrix-js-sdk"); +var dis = require("../../dispatcher"); + +var ComponentBroker = require("../../ComponentBroker"); + +var ServerConfig = ComponentBroker.get("molecules/ServerConfig"); + +module.exports = { + getInitialState: function() { + return { + step: 'initial', + busy: false, + currentStep: 0, + totalSteps: 1 + }; + }, + + componentWillMount: function() { + this.readNewProps(); + }, + + componentWillReceiveProps: function() { + this.readNewProps(); + }, + + readNewProps: function() { + if (this.props.clientSecret && this.props.hsUrl && + this.props.isUrl && this.props.sessionId && + this.props.idSid) { + this.authSessionId = this.props.sessionId; + MatrixClientPeg.replaceUsingUrls( + this.props.hsUrl, + this.props.isUrl + ); + this.setState({ + hs_url: this.props.hsUrl, + is_url: this.props.isUrl + }); + this.savedParams = {client_secret: this.props.clientSecret}; + this.setState({busy: true}); + + var isLocation = document.createElement('a'); + isLocation.href = this.props.isUrl; + + var auth = { + type: 'm.login.email.identity', + threepid_creds: { + sid: this.props.idSid, + client_secret: this.savedParams.client_secret, + id_server: isLocation.host + } + }; + this.tryRegister(auth); + } + }, + + componentDidUpdate: function() { + // Just putting a script tag into the returned jsx doesn't work, annoyingly, + // so we do this instead. + if (this.refs.recaptchaContainer) { + var scriptTag = document.createElement('script'); + window.mx_on_recaptcha_loaded = this.onCaptchaLoaded; + scriptTag.setAttribute('src', "https://www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit"); + this.refs.recaptchaContainer.getDOMNode().appendChild(scriptTag); + } + }, + + setStep: function(step) { + this.setState({ step: step, errorText: '', busy: false }); + }, + + getSupportedStageTypes: function() { + return ['m.login.email.identity', 'm.login.recaptcha']; + }, + + chooseFlow: function(flows) { + // this is fairly simple right now + var supportedTypes = this.getSupportedStageTypes(); + + var emailFlow = null; + var otherFlow = null; + for (var flowI = 0; flowI < flows.length; ++flowI) { + var flow = flows[flowI]; + var flowHasEmail = false; + var flowSupported = true; + for (var stageI = 0; stageI < flow.stages.length; ++stageI) { + var stage = flow.stages[stageI]; + + if (supportedTypes.indexOf(stage) == -1) { + flowSupported = false; + } + + if (stage == 'm.login.email.identity') { + flowHasEmail = true; + } + } + if (flowSupported) { + if (flowHasEmail) { + emailFlow = flow; + } else { + otherFlow = flow; + } + } + } + + if ( + this.savedParams.email != '' || + this.completedStages.indexOf('m.login.email.identity' > -1) + ) { + return emailFlow; + } else { + return otherFlow; + } + }, + + firstUncompletedStageIndex: function(flow) { + if (this.completedStages === undefined) return 0; + for (var i = 0; i < flow.stages.length; ++i) { + if (this.completedStages.indexOf(flow.stages[i]) == -1) { + return i; + } + } + }, + + numCompletedStages: function(flow) { + if (this.completedStages === undefined) return 0; + var nCompleted = 0; + for (var i = 0; i < flow.stages.length; ++i) { + if (this.completedStages.indexOf(flow.stages[i]) > -1) { + ++nCompleted; + } + } + return nCompleted; + }, + + onInitialStageSubmit: function(ev) { + ev.preventDefault(); + MatrixClientPeg.replaceUsingUrls( + this.refs.serverConfig.getHsUrl(), + this.refs.serverConfig.getIsUrl() + ); + this.setState({ + hs_url: this.refs.serverConfig.getHsUrl(), + is_url: this.refs.serverConfig.getIsUrl() + }); + var cli = MatrixClientPeg.get(); + this.setState({busy: true}); + var self = this; + + this.savedParams = { + email: this.refs.email.getDOMNode().value, + username: this.refs.username.getDOMNode().value, + password: this.refs.password.getDOMNode().value + }; + + this.tryRegister(); + }, + + startStage: function(stageName) { + var self = this; + this.setStep('stage_'+stageName); + switch(stageName) { + case 'm.login.email.identity': + self.setState({ + busy: true + }); + var cli = MatrixClientPeg.get(); + this.savedParams.client_secret = cli.generateClientSecret(); + this.savedParams.send_attempt = 1; + + var nextLink = this.props.registrationUrl + + '?client_secret=' + + encodeURIComponent(this.savedParams.client_secret) + + "&hs_url=" + + encodeURIComponent(this.state.hs_url) + + "&is_url=" + + encodeURIComponent(this.state.is_url) + + "&session_id=" + + encodeURIComponent(this.authSessionId); + + cli.requestEmailToken( + this.savedParams.email, + this.savedParams.client_secret, + this.savedParams.send_attempt, + nextLink + ).done(function(response) { + self.setState({ + busy: false, + }); + self.setStep('stage_m.login.email.identity'); + }, function(error) { + self.setState({ + busy: false, + errorText: 'Unable to contact the given Home Server' + }); + }); + break; + case 'm.login.recaptcha': + if (!this.authParams || !this.authParams['m.login.recaptcha'].public_key) { + this.setState({ + errorText: "This server has not supplied enough information for Recaptcha authentication" + }); + } + break; + } + }, + + onRegistered: function(user_id, access_token) { + MatrixClientPeg.replace(Matrix.createClient({ + baseUrl: this.state.hs_url, + idBaseUrl: this.state.is_url, + userId: user_id, + accessToken: access_token + })); + var localStorage = window.localStorage; + if (localStorage) { + localStorage.setItem("mx_hs_url", this.state.hs_url); + localStorage.setItem("mx_user_id", user_id); + localStorage.setItem("mx_access_token", access_token); + } else { + console.warn("No local storage available: can't persist session!"); + } + if (this.props.onLoggedIn) { + this.props.onLoggedIn(); + } + }, + + componentForStep: function(step) { + switch (step) { + case 'initial': + return ( +
+
+ Email:
+ Username:
+ Password:
+ + + +
+ ); + // XXX: clearly these should be separate organisms + case 'stage_m.login.email.identity': + return ( +
+ Please check your email to continue registration. +
+ ); + case 'stage_m.login.recaptcha': + return ( +
+ This Home Server would like to make sure you're not a robot +
+
+ ); + } + }, + + onCaptchaLoaded: function() { + if (this.refs.recaptchaContainer) { + var sitekey = this.authParams['m.login.recaptcha'].public_key; + global.grecaptcha.render('mx_recaptcha', { + 'sitekey': sitekey, + 'callback': this.onCaptchaDone + }); + } + }, + + onCaptchaDone: function(captcha_response) { + this.tryRegister({ + type: 'm.login.recaptcha', + response: captcha_response + }); + }, + + tryRegister: function(auth) { + var self = this; + MatrixClientPeg.get().register( + this.savedParams.username, + this.savedParams.password, + this.authSessionId, + auth + ).done(function(result) { + self.onRegistered(result.user_id, result.access_token); + }, function(error) { + if (error.httpStatus == 401 && error.data.flows) { + self.authParams = error.data.params; + self.authSessionId = error.data.session; + + self.completedStages = error.data.completed; + + var flow = self.chooseFlow(error.data.flows); + + var flowStage = self.firstUncompletedStageIndex(flow); + var numDone = self.numCompletedStages(flow); + + self.setState({ + busy: false, + flows: flow, + currentStep: 1+numDone, + totalSteps: flow.stages.length+1, + flowStage: flowStage + }); + self.startStage(flow.stages[flowStage]); + } else { + var errorText = "Unable to contact the given Home Server"; + if (error.httpStatus == 401) { + errorText = "Authorisation failed!"; + } + self.setStep("initial"); + self.setState({ + busy: false, + errorText: errorText + }); + } + }); + }, + + showLogin: function(ev) { + ev.preventDefault(); + dis.dispatch({ + action: 'start_login' + }); + } +};