In this tutorial, we're going to build an application that shows how to integrate React, React Router, and Horizon.io with OAuth authentication from beginning to end.
The application is simple. It just stores and presents messages in real-time:
The entire source code is available on Github. Big thanks to Bartek Kus who help me update the code to Horizon 2.0.
Horizon.io is a real-time backend for Javascript apps built on top of RethinkDB. If you don't know this NoSQL database, here's a tutorial that shows how it works.
So first, you'll need to install RethinkDB. There are packages for all major operative systems, here are the instructions.
Then, install Horizon (globally) by executing:
1npm install -g horizon
We'll be using Horizon's version 2.0.0, if you need/want to specify this version, execute this command instead:
1npm install -g [email protected]
About React, you don't need to be a guru to follow this tutorial, but you'll need to have some basic knowledge about components and how this library works.
Now that we have all we need, let's get started.
First, execute the following command:
1hz init react-horizon
This will create a Horizon application in the directory react-horizon
with the following structure:
1.hz
2 |- config.toml
3 |- schema.toml
4 |- secret.toml
5dist
6 |- index.html
7src
8.gitignore
.hz/config.toml
is the TOML main configuration file for the Horizon server.
.hz/schema.toml
is optionally used for the database schema and permissions.
.hz/secret.toml
specifies authentication information and token secrets.
You can know more about these configuration files here.
dist
is the directory where the public and static files will be stored.
src
is the directory where the client side code will be stored.
.gitignore
contains the following:
1rethinkdb_data
2**/*.log
3.hz/secrets.toml
For authentication, Horizon requires us to work with https
, so we need to hava a SSL certificate. Luckily, Horizon comes with a tool to create a self-signed certificates (you just need to have OpenSSL installed), so cd
into this directory
1cd react-horizon
To keep things organized, let's create a directory to store all the configuration files of our app and cd
into it:
1mkdir config && cd config
Now create a directory to store the certificates:
1mkdir tls && cd tls
And execute the command:
1hz create-cert
This will create a horizon-cert.pem
and a horizon-key.pem
. By default, Horizon will look for these files in the root directory of the application when starting a secure server. If you want to change the name or the location of these files (as in our case), uncomment and change the following section of the .hz/config.toml
file:
1###############################################################################
2# HTTPS Options
3# 'secure' will disable HTTPS and use HTTP instead when set to 'false'
4# 'key_file' and 'cert_file' are required for serving HTTPS
5#------------------------------------------------------------------------------
6# secure = true
7# key_file = "horizon-key.pem"
8# cert_file = "horizon-cert.pem"
To:
1###############################################################################
2# HTTPS Options
3# 'secure' will disable HTTPS and use HTTP instead when set to 'false'
4# 'key_file' and 'cert_file' are required for serving HTTPS
5#------------------------------------------------------------------------------
6secure = true
7key_file = "config/tls/horizon-key.pem"
8cert_file = "config/tls/horizon-cert.pem"
Now, the command to start the server in a development environment is:
1hz serve --dev
The --dev
option will set the following flags:
--start-rethinkdb
that will start a RethinkDB server automatically--secure no
that will start an insecure server (with no SSL)--permissions no
that will disable the permissions system----auto-create-collection
and --auto-create-index
that will create tables and indexes if they don't exist--server-static ./dist
that will configure dist
as the directory from which the static content will be servedHowever, since we are going to use HTTPS, we need to redefine the secure
option, so go to the root directory of the app (cd ../..
) and start the server with the following command:
1hz serve --dev --secure yes
The output of this command should be similar to the following:
1App available at https://127.0.0.1:8181
2RethinkDB
3 ├── Admin interface: http://localhost:46398
4 └── Drivers can connect to port 35109
5Starting Horizon...
6🌄 Horizon ready for connections
If you go to https://localhost:8181 you should see this (after accepting the warning of the self-signed certificate):
Moreover, a RethinkDB server will be started automatically and a rethinkdb-data
directory will be created. When you go to http://localhost:46398 (or whatever address Horizon gives you in the console), and then to the Tables section, you should see the following:
As you can see, Horizon has created a database with the name of the project and stores metadata about collections, users, and groups. Also, it will store the data used by collections in the application.
We're going to use ECMAScript 2015 so let's set up Babel to transform this syntax to one most browsers can understand by creating its configuration file:
1echo '{ "presets": ["react", "es2015", "stage-0"] }' > .babelrc
Babel 6.x does not ship with any transformations enabled, so you need to explicitly tell it what transformations to run by using a preset.
The first two are very self-descriptive. The stage-x presets are changes to the language that haven’t been approved to be part of a release of Javascript.
The TC39 categorizes proposals into 4 stages:
stage-0
includes all plugins from presets of all levels. stage-1
includes all plugins from presets 1 to 4 and so on.
To execute Babel and bundle our scripts with their dependencies, we'll use Webpack and npm. Let's install Webpack (globally) with:
1npm install -g webpack
And add a package.json
configuration file with:
1npm init
Or if you want to accept all the defaults:
1npm init -y
Next, install the Babel dependencies and presets (and a polyfill to emulate a full ES2015 environment) with:
1npm install --save-dev babel-core babel-loader
2npm install --save-dev babel-preset-es2015 babel-preset-react babel-preset-stage-0 babel-polyfill
Do the same with Webpack and React:
1npm install --save-dev webpack
2npm install --save react react-dom react-router
At the time of this writing, the following are the versions saved to the package.json
file:
1{
2 ...
3 "devDependencies": {
4 "babel-core": "^6.13.2",
5 "babel-loader": "^6.2.5",
6 "babel-polyfill": "^6.13.0",
7 "babel-preset-es2015": "^6.13.2",
8 "babel-preset-react": "^6.11.1",
9 "babel-preset-stage-0": "^6.5.0",
10 "webpack": "^1.13.2"
11 },
12 "dependencies": {
13 "react": "^15.3.1",
14 "react-dom": "^15.3.1",
15 "react-router": "^2.7.0"
16 }
17}
Now, create a webpack.config.js
file at root level with the following content:
1var path = require('path');
2
3module.exports = {
4 entry: ["./src/app.js"],
5
6 output: {
7 filename: "dist/js/bundle.js",
8 sourceMapFilename: "dist/js/bundle.map"
9 },
10
11 devtool: '#source-map',
12
13 module: {
14 loaders: [
15 {
16 loader: 'babel',
17 exclude: /node_modules/
18 }
19 ]
20 }
21}
This way, Webpack will create a dist/js/bundle.js
file with all the JavaScript code of the application (from the script /src/app.js
).
Finally, let's add to package.json
the following start
script that packs our application and starts the Horizon server:
1{
2 ...
3 "scripts": {
4 "start": "webpack && hz serve --dev --secure yes"
5 },
6 ...
7}
Let's start by defining the HTML file that will contain our React application, dist/index.html
:
1<!doctype html>
2<html>
3 <head>
4 <meta charset="UTF-8">
5 <title>React.js + Horizon.io</title>
6 <link rel=stylesheet href=/css/style.css />
7 </head>
8 <body>
9 <h1>React.js + Horizon.io</h1>
10 <div id="root"></div>
11 <script src="/js/bundle.js"></script>
12 </body>
13</html>
And the CSS to style it, dist/css/style.css
:
1body {
2 background: #300637;
3 padding: 4em;
4 text-align: center;
5 font-family: "Arial, Helvetica, sans-serif";
6 color: #c0c0c0;
7}
8
9h1 {
10 font-weight: 100;
11}
12
13.menu {
14 margin-top: 3em;
15 margin-bottom: 5em;
16}
17
18.menu-option {
19 color: #999;
20 background: rgba(0, 0, 0, 0.5);
21 padding: 1em 5em;
22 font-size: 0.8em;
23 text-decoration: none;
24 letter-spacing: 2px;
25 text-transform: uppercase;
26}
27
28.menu-option:hover {
29 background: rgba(0, 0, 0, 0.4);
30 color: #fff;
31}
32
33.menu-option.active {
34 border: none;
35 background: rgba(0, 0, 0, 0.4);
36 background: #fff;
37 padding: 1.5em 5em;
38 color: #000;
39 font-size: 0.9em;
40}
41
42.login-btn {
43 border: none;
44 color: #999;
45 background: rgba(0, 0, 0, 0.5);
46 padding: 1.5em 5em;
47 font-size: 0.9em;
48 text-transform: uppercase;
49 cursor: pointer;
50}
51
52.message-input {
53 display: block;
54 margin: 0.5em;
55 padding: 0.8em;
56 width: 90%;
57 border: none;
58 font-family: sans-serif;
59 font-size: 0.9em;
60}
61
62.message-container {
63 margin: 2em auto;
64 padding: 0.8em;
65 width: 80%;
66}
67
68.message-btn {
69 padding: 0.75rem;
70 margin: 0.5em auto 1.5em;
71 width: 6em;
72 text-align: center;
73 cursor: pointer;
74}
75
76.message-row {
77 padding: 0.75em;
78 margin: 0.75em;
79 box-shadow: 0 0 1px 1px #999;
80}
src
will store the JavaScript code of our application. The starting file will be app.js
:
1import React from "react";
2import ReactDOM from "react-dom";
3
4import Router from "./router";
5
6ReactDOM.render(Router, document.getElementById("root"));
This way, router.js
will contain all the routes of the application:
1import React from "react";
2import { Router, Route, browserHistory, IndexRoute } from "react-router";
3
4// Layout
5import MainLayout from "./components/main-layout";
6
7// Pages
8import Home from "./components/home";
9import MessageContainer from "./components/message-container";
10import Login from "./components/login";
11
12export default (
13 <Router history={browserHistory}>
14 <Route path="/" component={MainLayout}>
15 <IndexRoute component={Home} />
16 <Route path="messages" component={MessageContainer} />
17 <Route path="login" component={Login} />
18 </Route>
19 </Router>
20);
We'll have three nested routes (so MainLayout
can serve as a template):
/
is our home/login
is our login page/messages
is the page with the message functionalityNow let's create the components that represent these pages in the components
directory.
components/main-layout.js
contains the general layout of the app:
1import React, { Component } from "react";
2import { Link } from "react-router";
3import Menu from "./menu";
4
5export default class MainLayout extends Component {
6 render() {
7 return (
8 <div className="app">
9 <div>
10 <Menu />
11 </div>
12 <div>{this.props.children}</div>
13 </div>
14 );
15 }
16}
The menu is delegated to the components/menu.js
component:
1import React, { Component } from "react";
2import { Link, IndexLink } from "react-router";
3
4export default class Menu extends Component {
5 render() {
6 var menu = (
7 <div className={"menu"}>
8 <IndexLink to="/" className={"menu-option"} activeClassName="active">
9 Home
10 </IndexLink>
11 <Link to="/messages" className={"menu-option"} activeClassName="active">
12 Messages
13 </Link>
14 <Link to="/login" className={"menu-option"} activeClassName="active">
15 Login
16 </Link>
17 </div>
18 );
19
20 return menu;
21 }
22}
An <IndexLink>
is like a <Link>
, but it's only active when the current route is exactly the linked route (otherwise, since all of these are nested routes, /
and /messages
will be marked as active at the same time).
For now, components/login.js
will just contain a button to login with Github:
1import React, { Component } from "react";
2
3export default class Login extends Component {
4 render() {
5 return (
6 <div>
7 <button className={"login-btn"}>Login with Github</button>
8 </div>
9 );
10 }
11}
components/home.js
will contain:
1import React, { Component } from "react";
2
3export default class Home extends Component {
4 render() {
5 return <h1>Home</h1>;
6 }
7}
While components/message-container.js
will contain:
1import React, { Component } from "react";
2
3export default class MessageContainer extends Component {
4 render() {
5 return <h1>Message Container</h1>;
6 }
7}
If you run the application at this point with:
1npm start
It should look like this:
Now let's use Horizon to add real-time functionality to the application.
First, add the Horizon client dependency to package.json
:
1npm install --save @horizon/client
You can force the installation of version 2.0.0 with:
1npm install --save @horizon/[email protected]
Then, we are going to create a container for the Horizon object, src/horizon-container.js
. This will allow us to use Horizon in any part of the application with the same settings, and wrap functions to use them easily. However, right now, it will contain just:
1import Horizon from "@horizon/client";
2
3const _horizon = Horizon();
4
5export default {
6 get: () => _horizon
7};
The Horizon()
object accepts some optional arguments that you can see in this page. Here we're just using the defaults.
In components/message-container.js
, we're going to create a Horizon collection to hold the messages. A collection represents a table in RethinkDB, and it lets you store, retrieve, and filter documents. In this page you can know about its API.
Here's the code:
1import React, { Component } from "react";
2import Messages from "./messages";
3import Horizon from "../horizon-container";
4
5const _horizon = Horizon.get();
6const _messageCollection = _horizon("messages");
7
8export default class MessageContainer extends Component {
9 constructor(props) {
10 super(props);
11 this.state = { text: "", errorDescription: "" };
12 }
13
14 handleChangeText = e => {
15 this.setState({ text: e.target.value });
16 };
17
18 handleSubmit = () => {
19 if (this.state.text === "" || this.state.author === "") {
20 this.setState({ errorDescription: "The message is required" });
21 } else {
22 this.storeMessage("", this.state.text);
23 }
24 };
25
26 storeMessage = (user, text) => {
27 const message = {
28 text: text,
29 author: user
30 };
31 _messageCollection.store(message);
32 this.setState({ text: "", errorDescription: "" });
33 };
34
35 render() {
36 return (
37 <div>
38 <div className={"message-container"}>
39 <div>{this.state.errorDescription}</div>
40 <input
41 onChange={this.handleChangeText}
42 className={"message-input"}
43 type="text"
44 placeholder="Enter your message"
45 value={this.state.text}
46 />
47 <button className={"message-btn"} onClick={this.handleSubmit}>
48 Submit
49 </button>
50 <Messages messages={_messageCollection} />
51 </div>
52 </div>
53 );
54 }
55}
Notice how we create the message
collection:
1const _messageCollection = _horizon("messages");
And how a message is inserted:
1_messageCollection.store(message);
The complete collection API is here.
In the next section we'll add the authenticated user ID. For now, we are just passing an empty string.
Also, notice that the component binds the method using arrow functions (since they capture the correct value of this
in the actual context) instead of using a regular function and bind(this)
:
1<button className={"message-btn"} onClick={this.handleSubmit.bind(this)}>
2 Submit
3</button>
Then, the message
collection is passed to a Messages
component (components/messages.js
):
1import React, { Component } from "react";
2import Message from "./message";
3
4export default class Messages extends Component {
5 constructor(props) {
6 super(props);
7 this.messageCollection = this.props.messages;
8 this.state = { messages: [] };
9 }
10
11 componentDidMount() {
12 this.messageCollection.watch().subscribe(
13 collection => {
14 if (collection) {
15 this.setState({ messages: collection });
16 }
17 },
18 err => {
19 console.log(err);
20 }
21 );
22 }
23
24 render() {
25 const messagesMapped = this.state.messages.map((result, index) => {
26 return <Message message={result} key={index} />;
27 });
28
29 return <div>{messagesMapped}</div>;
30 }
31}
The watch()
method allows us to listen for changes in the collection in real-time. It returns an object that receives the entire collection of documents, even when a single one changes, which fits perfectly with the way React works.
This way, everything a change is detected, the state of the component changes, and this is re-rendered.
The final piece is the Message
component (components/message.js
), which just prints a single message:
1import React, { Component } from "react";
2
3export default class Message extends Component {
4 constructor(props) {
5 super(props);
6 }
7
8 render() {
9 const { text, author } = this.props.message;
10 return (
11 <div className={"message-row"}>
12 <div>
13 <b>Author:</b> {author}
14 </div>
15 <div>
16 <b>Text:</b> {text}
17 </div>
18 </div>
19 );
20 }
21}
If we run the application at this point, it should look like this:
And in the console, the following messages will be shown (once you've entered to the Messages page):
1warn: Auto-creating collection (dev mode): messages
2warn: Collection created (dev mode): "messages"
If you go to the RethinkDB web interface (remember to look for the URL when you start the server), you'll notice the table for the collection (in this case, messages
):
If you query this table, you'll see the stored message(s):
Let's add authentication to our app by using Horizon's support for OAuth. For simplicity, we're going to use Github only, but the steps and configuration are almost the same for other providers (like Google or Twitter).
We'll need a client ID and a client secret. Go to https://github.com/settings/applications/new to register an application and enter the following information:
When you register the application, the client ID and client secret will be presented:
Then, you have to configure these values in the .hz/secrets.toml
file:
1token_secret = "NnvpIpep8g9msem6pQHap6g38/wZ0GYQH9/NtXnUTRWlSHT28UtrbAHxxJhi+7673koIJx2Ay5kFX+zHua3fjQ=="
2
3###############################################################################
4# RethinkDB Options
5# 'rdb_user' is the user account to log in with when connecting to RethinkDB
6# 'rdb_password' is the password for the user account specified by 'rdb_user'
7#------------------------------------------------------------------------------
8# rdb_user = 'admin'
9# rdb_password = ''
10
11# [auth.auth0]
12# host = "0000.00.auth0.com"
13# id = "0000000000000000000000000"
14# secret = "00000000000000000000000000000000000000000000000000"
15# redirect_url = ""
16#
17# [auth.facebook]
18# id = "000000000000000"
19# secret = "00000000000000000000000000000000"
20#
21# [auth.google]
22# id = "00000000000-00000000000000000000000000000000.apps.googleusercontent.com"
23# secret = "000000000000000000000000"
24#
25# [auth.twitter]
26# id = "0000000000000000000000000"
27# secret = "00000000000000000000000000000000000000000000000000"
28#
29# [auth.github]
30# id = "00000000000000000000"
31# secret = "0000000000000000000000000000000000000000"
32#
33# [auth.twitch]
34# id = "0000000000000000000000000000000"
35# secret = "0000000000000000000000000000000"
36#
37# [auth.slack]
38# id = "0000000000000000000000000000000"
39# secret = "0000000000000000000000000000000"
Horizon uses JSON Web Tokens (JWTS) for user authentication. When the .hz/secrets.toml
file is created, a token_secret
is generated to sign JWTS, so just replace the client ID and the client secret and uncomment the Github section:
1[auth.github]
2id = "ea89158619f776d0703c"
3secret = "df0ab9c5bff3aae9209ac08241cc7fbdd4ab4144"
Now, let's modify the file src/horizon-container.js
to support authentication. First, configure Horizon to support authentication with JWTS:
1const _horizon = Horizon({authType: 'token'});
Then, add the method clearAuthTokens()
to delete the token on logout and a method to get the current user. The code should look like this:
1import Horizon from "@horizon/client";
2
3const _horizon = Horizon({ authType: "token" });
4
5export default {
6 get: () => _horizon,
7 clearAuthTokens: () => Horizon.clearAuthTokens(),
8 getCurrentUser: callback => {
9 _horizon
10 .currentUser()
11 .fetch()
12 .subscribe(user => callback(user));
13 }
14};
There is more than one way to implement authentication in a React/React Router application. The one that we'll use in this tutorial is high-order components.
A high-order component is a function that takes another component and returns another one that wraps it. In our case, a high-order component will be used to wrap the route to protect by checking if the user is authenticated before rendering it.
This will be the job of src/authenticate-route.js
. Here's the code:
1import React, { Component, PropTypes } from "react";
2import Login from "./components/login";
3import Horizon from "./horizon-container";
4
5const _horizon = Horizon.get();
6
7export default ChildComponent => {
8 class AuthenticatedComponent extends Component {
9 constructor(props) {
10 super(props);
11 this.state = { currentUser: "" };
12 }
13
14 componentDidMount() {
15 if (_horizon.hasAuthToken()) {
16 Horizon.getCurrentUser(user => {
17 this.setState({ currentUser: user.id });
18 });
19 }
20 }
21
22 render() {
23 return _horizon.hasAuthToken() ? (
24 <ChildComponent {...this.props} user={this.state.currentUser} />
25 ) : (
26 <Login />
27 );
28 }
29 }
30
31 return AuthenticatedComponent;
32};
The user ID will be stored in the state and passed to the component that wraps. This way, when it changes (because it's fetched asynchronously), the child component will be re-rendered.
In the render()
function, we check if the authentication token is present so the child component can be rendered. Otherwise, the login page is presented.
In the Login
component (components/login.js
), we add the logic to present the Github login with the method authEndpoint()
:
1import React, { Component } from "react";
2import Horizon from "../horizon-container";
3
4const _horizon = Horizon.get();
5
6export default class Login extends Component {
7 handleAuth = () => {
8 _horizon.authEndpoint("github").subscribe(endpoint => {
9 window.location.replace(endpoint);
10 });
11 };
12
13 render() {
14 return (
15 <div>
16 <button className={"login-btn"} onClick={this.handleAuth}>
17 Login with Github
18 </button>
19 </div>
20 );
21 }
22}
This way, we protect a route (in src/router.js
), in this case /message
, like this:
1import authenticate from './authenticate-route'
2
3...
4
5export default (
6 <Router history={browserHistory}>
7 <Route path="/" component={MainLayout}>
8 <IndexRoute component={Home} />
9 <Route path="messages" component={authenticate(MessageContainer)} />
10 <Route path="login" component={Login} />
11 </Route>
12 </Router>
13);
Also, for this application we're going to show a different menu for authenticated users. Open the file src/components/menu.js
and modify it so it looks like this:
1import React, { Component } from "react";
2import { Link, IndexLink } from "react-router";
3import Horizon from "../horizon-container";
4
5export default class Menu extends Component {
6 logout = e => {
7 e.preventDefault();
8 Horizon.clearAuthTokens();
9 this.context.router.push("/");
10 };
11
12 render() {
13 var menu = Horizon.get().hasAuthToken() ? (
14 <div className={"menu"}>
15 <IndexLink to="/" className={"menu-option"} activeClassName="active">
16 Home
17 </IndexLink>
18 <Link to="/messages" className={"menu-option"} activeClassName="active">
19 Messages
20 </Link>
21 <a href="#" className={"menu-option"} onClick={this.logout}>
22 Log out
23 </a>
24 </div>
25 ) : (
26 <div className={"menu"}>
27 <IndexLink to="/" className={"menu-option"} activeClassName="active">
28 Home
29 </IndexLink>
30 <Link to="/login" className={"menu-option"} activeClassName="active">
31 Login
32 </Link>
33 </div>
34 );
35
36 return menu;
37 }
38}
39
40Menu.contextTypes = {
41 router: React.PropTypes.object
42};
Using the method hasAuthToken()
, we're going to show a menu with the Messages and Logout options for authenticated users.
The logout
function deletes the token and redirects the user to the home page. In order to do this, we need the router object, and since we're using an ES6 class, we have to inject it like this:
1Menu.contextTypes = {
2 router: React.PropTypes.object
3};
The authentication component passes to the child the ID of the user, so let's modify the message components to use it.
In components/message-container.js
you just need to update the handleSubmit()
method to get the ID from the properties:
1handleSubmit = () => {
2 if (this.state.text === "" || this.state.author === "") {
3 this.setState({ errorDescription: "The message is required" });
4 } else {
5 this.storeMessage(this.props.user, this.state.text);
6 }
7};
And pass it to the Messages
component:
1render() {
2 return (
3 <div>
4 <div className={'message-container'}>
5 <div>{this.state.errorDescription}</div>
6 <input onChange={this.handleChangeText} className={'message-input'} type='text' placeholder='Enter your message' value={this.state.text}/>
7 <button className={'message-btn'} onClick={this.handleSubmit}>Submit</button>
8 <Messages messages={_messageCollection} user={this.props.user}/>
9 </div>
10 </div>
11 );
12}
This way, the Messages
component can use it to filter the messages:
1export default class Messages extends Component {
2
3 constructor(props) {
4 super(props);
5 this.messageCollection = this.props.messages;
6 this.userId = '';
7 this.state = {messages: []};
8 }
9
10 componentWillReceiveProps(nextProps) {
11 if (nextProps.user !== this.userId) {
12 this.userId = nextProps.user;
13 this.messageCollection.findAll({author: this.userId}).watch().subscribe(
14 (collection) => {
15 if(collection) {
16 this.setState({messages: collection});
17 }
18 },
19 (err) => {
20 console.log(err);
21 }
22 );
23 }
24 }
25
26 render() {
27 ...
28 }
29}
Notice that we had to replace componentDidMount()
by componentWillReceiveProps(nextProps)
.
The reason is that componentDidMount()
of the child components is invoked before that of parent components. So, when the user ID is received, the child components are re-rendered but the componentDidMount()
method of components/messages.js
is not executed again with this value, so the changes of that user are not received.
componentWillReceiveProps(nextProps)
is invoked when a component is receiving new properties and we can be used to update the state due to a property change before render()
is executed.
However, this will make the component continuously re-render (because of the state changes of components/message-container.js
), so we have to put a condition to only execute the watch()
query when we first receive the user ID:
1componentWillReceiveProps(nextProps) {
2 if (nextProps.user !== this.userId) {
3 this.userId = nextProps.user;
4 this.messageCollection.findAll({author: this.userId}).watch().subscribe(
5 ...
6 );
7 }
8}
Finally, we're ready to test the authentication. Execute npm start
to pack the changes and start the server.
The first time you log into the application, it will prompt you to authorize it on Github:
Notice that the menu now changes for (un)authenticated users and, since we're filtering by user ID, the previously entered messages are not shown.
In the logs of the server, you should also see this line:
1warn: Auto-creating index on collection "users": [["id"]]
2warn: Auto-creating index on collection "messages": [["author"]]
In development mode, Horizon will create an index to speed up the queries automatically.
After this, every time you log into the application, if you have a session on Github, it should log you in automatically, otherwise, it will prompt you for your credentials.
Additionally, you can go to https://github.com/settings/developers to view the number of registered user in your application and change the settings if you want.
Now that we have the authentication part done, we just have one final problem to solve.
The routes work by clicking on the links, but what happens when we enter directly the route in the browser:
This is because the Horizon server doesn't know about the routes configured in React, it just serves whatever files are in the dist
directory.
However, we need to make some major changes:
hz serve
, which means that a RethinkDB server won't be automatically started..hz/config.toml
holds the configuration of the framework. When using Horizon on the server side, we need to pass all these configurations to the server when we create it.Let's start by adding the dependencies we're going to need to our package.json
file:
1npm install --save express path @horizon/server
Once again, you can force the installation of version 2.0.0 with:
1npm install --save @horizon/[email protected]
Next, let's add a server.js
file to the root directory, importing the libraries we're going to need and creating the Express object:
1const express = require("express");
2const https = require("https");
3const path = require("path");
4const fs = require("fs");
5const horizon = require("@horizon/server");
6
7const app = express();
Now configure the routes for the public files and to redirect all requests to index.html
1// Serve our static stuff like css
2app.use(express.static(path.join(__dirname, "dist")));
3
4// Send all requests to index.html
5app.get("*", function(req, res) {
6 res.sendFile(path.join(__dirname, "dist", "index.html"));
7});
Next, configure Express to use HTTPS. We can reuse our self-signed certificates:
1const options = {
2 key: fs.readFileSync(path.resolve(__dirname, "./config/tls/horizon-key.pem")),
3 cert: fs.readFileSync(
4 path.resolve(__dirname, "./config/tls/horizon-cert.pem")
5 )
6};
7const PORT = process.env.PORT || 8181;
8
9const server = https.createServer(options, app);
10
11server.listen(PORT, function() {
12 console.log("Express server running at localhost:" + PORT);
13});
Now that we have an HTTPS server, we configure Horizon to use it. We'll need to copy the token_secret
and Github client ID and client secret from the .hz/config.toml
file:
1const horizon_server = horizon(server, {
2 project_name: "react_horizon",
3 permissions: true,
4 auth: {
5 token_secret:
6 "NnvpIpep8g9msem6pQHap6g38/wZ0GYQH9/NtXnUTRWlSHT28UtrbAHxxJhi+7673koIJx2Ay5kFX+zHua3fjQ=="
7 }
8});
9
10// Add Github authentication
11horizon_server.add_auth_provider(horizon.auth.github, {
12 path: "github",
13 id: "2660ef72dc60e109b088",
14 secret: "a844d51e652d74a6760ab71815050cba58a70d88"
15});
As you can see, the server is passed to Horizon along with options that are similar to the ones defined in the .hz/config.toml
file. In the above code, we set the project name, enable permissions (more of this in a moment), and use token authentication. You can find more information about all the options here.
Finally, we use the add_auth_provider
method to set up the OAuth Github provider. You can extract all this configuration data to an external file if you want (like a config.js
file).
By default, Horizon will connect to a RethinkDB server on localhost:28015
. We're also enabling permissions (which is actually the default option).
In this configuration, Horizon doesn't allow access to collections by default, even for authenticated users, which means that we won't be able to use neither our message
collection nor getting the user ID (from the users
table).
To fix this, we need to manually import the permission rules to the database we're going to use. If you execute this command:
1hz schema save -n react_horizon --start-rethinkdb yes -o schema.toml
Horizon will start the development RethinkDB server, and extract the schema, validation rules, collection and index specifications of the react_horizon
application (it won't extract the collection's data) as a TOML file, schema.toml
. Here's what this file contains:
1# This is a TOML document
2
3[collections.messages]
4[[collections.messages.indexes]]
5fields = [["author"]]
6
7[collections.users]
8[[collections.users.indexes]]
9fields = [["id"]]
10
11[groups.admin]
12[groups.admin.rules.carte_blanche]
13template = "any()"
Since we were using Horizon in development mode (where no permissions are enforced), there are no permissions rules set up, so we will have to add the following to schema.toml
:
1[groups.authenticated.rules.read_own_messages]
2template = "collection('messages').findAll({author: userId()})"
3
4[groups.authenticated.rules.write_own_messages]
5template = "collection('messages').store({author: userId(), text: any()})"
6
7[groups.default.rules.read_current_user]
8template = "collection('users').find({id: userId()})"
You can learn about permission rules in this page, but what the above lines do is to allow the authenticated user to read and write their own messages, and to read the users
table to get the data by their ID.
Move this file to config/rethinkdb/schema.toml
, once again, to keep things organized.
To import these rules to our new database, first you have to start it (in another terminal and, preferably, in another directory) with:
1rethinkdb
And then, execute this command from the root directory of the application:
1hz schema apply -n react_horizon -c localhost:28015 config/rethinkdb/schema.toml
If you go to http://localhost:8080/#tables, you should see the imported databases:
And now that we won't be using the files of the .hz
directory and the rethinkdb-data
directory, you can delete them.
Finally, change the start
script on package.json
to start the Express server instead of the Horizon development server:
1{
2 ...
3 "scripts": {
4 "start": "webpack && node server.js"
5 },
6 ...
7}
When you run the application (don't forget to start the RethinkDB server also), the problem with entering the URL directly in the browser should be solved:
You can also test the rest of the functionality to make sure everything is working correctly.
There you have it. We created a single page application with authentication and real-time capabilities using React, React Router, Webpack, Babel, Node.js, Express, and of course, Horizon.
There are some limitations of what you can do with Horizon because, at the time of this writing, it is a relatively new framework. However, it has a lot of features that make real-time programming easier, not without mentioning its growing and strong community.
Remember that the code of the application is on Github and if you have any questions or comments, don' hesitate to contact me.