Phoenix Todos - The User Model

This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end.

Each commit in the project is represented by a section of the article. Click each section's header to see the commit on Github, or check out the repository and follow along.

Written by Pete Corey on Sep 7, 2016.

Create Users Table

Let’s focus on adding users and authorization to our Todos application. The first thing we’ll need is to create a database table to hold our users and a corresponding users schema.

Thankfully, Phoenix comes with many generators that ease the process of creating things like migrations and models.

To generate our users migration, we’ll run the following mix command:


mix phoenix.gen.model User users email:string encrypted_password:string

We’ll modify the migration file the generator created for us and add NOT NULL restrictions on both the email and encrypted_password fields:


add :email, :string, null: false
add :encrypted_password, :string, null: false

We’ll also add an index on the email field for faster queries:


create unique_index(:users, [:email])

Great! Now we can run that migration with the mix ecto.migrate command.

priv/repo/migrations/20160901141548_create_user.exs

+defmodule PhoenixTodos.Repo.Migrations.CreateUser do + use Ecto.Migration + + def change do + create table(:users) do + add :email, :string, null: false + add :encrypted_password, :string, null: false + + timestamps + end + + create unique_index(:users, [:email]) + end +end

Creating the Users Model

Now that we’re created our users table, we need to create a corresponding User model. Phoenix actually did most of the heavy lifting for us when we ran the mix phoenix.gen.model command.

If we look in /web/models, we’ll find a user.ex file that holds our new User model. While the defaults generated for us are very good, we’ll need to make a few tweaks.

In addition to the :email and :encrypted_password fields, we’ll also need a virtual :password field.


field :password, :string, virtual: true

:password is virtual because it will be required by our changeset function, but will not be stored in the database.

Speaking of required fields, we’ll need to update our @required_fields and @optional_fields attributes to reflect the changes we’ve made:


@required_fields ~w(email password)
@optional_fields ~w(encrypted_password)

These changes to @required_fields break our auto-generated tests against the User model. We’ll need to update the @valid_attrs attribute in test/models/user_test.ex and replace :encrypted_password with :password:


@valid_attrs %{email: "user@example.com", password: "password"}

And with that, our tests flip back to green!

test/models/user_test.exs

+defmodule PhoenixTodos.UserTest do + use PhoenixTodos.ModelCase + + alias PhoenixTodos.User + + @valid_attrs %{email: "user@example.com", password: "password"} + @invalid_attrs %{} + + test "changeset with valid attributes" do + changeset = User.changeset(%User{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset with invalid attributes" do + changeset = User.changeset(%User{}, @invalid_attrs) + refute changeset.valid? + end +end

web/models/user.ex

+defmodule PhoenixTodos.User do + use PhoenixTodos.Web, :model + + schema "users" do + field :email, :string + field :password, :string, virtual: true + field :encrypted_password, :string + + timestamps + end + + @required_fields ~w(email password) + @optional_fields ~w(encrypted_password) + + @doc """ + Creates a changeset based on the `model` and `params`. + + If no params are provided, an invalid changeset is returned + with no validation performed. + """ + def changeset(model, params \\ :empty) do + model + |> cast(params, @required_fields, @optional_fields) + end +end

Additional Validation

While the default required/optional field validation is a good start, we know that we’ll need additional validations on our User models.

For example, we don’t want to accept email addresses without the "@" symbol. We can write a test for this in our UserTest module:


test "changeset with invalid email" do
  changeset = User.changeset(%User{}, %{
    email: "no_at_symbol",
    password: "password"
  })
  refute changeset.valid?
end

Initially this test fails, but we can quickly make it pass by adding basic regex validation to the :email field in our User.changeset function:


|> validate_format(:email, ~r/@/)

We can repeat this process for all of the additional validation we need, like checking password length, and asserting email uniqueness.

test/models/user_test.exs

... - alias PhoenixTodos.User + alias PhoenixTodos.{User, Repo} ... end + + test "changeset with invalid email" do + changeset = User.changeset(%User{}, %{ + email: "no_at_symbol", + password: "password" + }) + refute changeset.valid? + end + + test "changeset with short password" do + changeset = User.changeset(%User{}, %{ + email: "email@example.com", + password: "pass" + }) + refute changeset.valid? + end + + test "changeset with non-unique email" do + User.changeset(%User{}, %{ + email: "email@example.com", + password: "password", + encrypted_password: "encrypted" + }) + |> Repo.insert! + + assert {:error, _} = User.changeset(%User{}, %{ + email: "email@example.com", + password: "password", + encrypted_password: "encrypted" + }) + |> Repo.insert + end end

web/models/user.ex

... |> cast(params, @required_fields, @optional_fields) + |> validate_format(:email, ~r/@/) + |> validate_length(:password, min: 5) + |> unique_constraint(:email, message: "Email taken") end

Hashing Our Password

You might have noticed that we had to manually set values for the encrypted_password field for our "changeset with non-unique email" test to run. This was to prevent the database from complaining about a non-null constraint violation.

Let’s remove those lines from our test and generate the password hash ourselves!

:encrypted_password was an unfortunate variable name choice. Our password is not being encrypted and stored in the database; that would be insecure. Instead we're storing the hash of the password.

We’ll use the comeonin package to hash our passwords, so we’ll add it as a dependency and an application in mix.exs:


def application do
  [...
   applications: [..., :comeonin]]
end

defp deps do
  [...
   {:comeonin, "~> 2.0"}]
end

Now we can write a private method that will update the our :encrypted_password field on our User model if its given a valid changeset that’s updating the value of :password:


defp put_encrypted_password(changeset = %Ecto.Changeset{
  valid?: true,
  changes: %{password: password}
}) do
  changeset
  |> put_change(:encrypted_password, Comeonin.Bcrypt.hashpwsalt(password))
end

We’ll use pattern matching to handle the cases where a changeset is either invalid, or not updating the :password field:


defp put_encrypted_password(changeset), do: changeset

Isn’t that pretty? And with that, our tests are passing once again.

mix.exs

... applications: [:phoenix, :phoenix_html, :cowboy, :logger, :gettext, - :phoenix_ecto, :postgrex]] + :phoenix_ecto, :postgrex, :comeonin]] end ... {:cowboy, "~> 1.0"}, - {:mix_test_watch, "~> 0.2", only: :dev}] + {:mix_test_watch, "~> 0.2", only: :dev}, + {:comeonin, "~> 2.0"}] end

mix.lock

-%{"connection": {:hex, :connection, "1.0.4"}, +%{"comeonin": {:hex, :comeonin, "2.5.2"}, + "connection": {:hex, :connection, "1.0.4"}, "cowboy": {:hex, :cowboy, "1.0.4"},

test/models/user_test.exs

... email: "email@example.com", - password: "password", - encrypted_password: "encrypted" + password: "password" }) ... email: "email@example.com", - password: "password", - encrypted_password: "encrypted" + password: "password" })

web/models/user.ex

... |> unique_constraint(:email, message: "Email taken") + |> put_encrypted_password end + + defp put_encrypted_password(changeset = %Ecto.Changeset{ + valid?: true, + changes: %{password: password} + }) do + changeset + |> put_change(:encrypted_password, Comeonin.Bcrypt.hashpwsalt(password)) + end + defp put_encrypted_password(changeset), do: changeset end

Final Thoughts

Things are starting to look very different from our original Meteor application. While Meteor tends to hide complexity from application developers by withholding code in the framework itself, Phoenix expects developers to write much of this boilerplate code themselves.

While Meteor’s methodology lets developers get off the ground quickly, Phoenix’s philosophy of hiding nothing ensures that there’s no magic in the air. Everything works just as you would expect; it’s all right in front of you!

Additionally, Phoenix generators ease most of the burden of creating this boilerplate code.

Now that our User model is in place, we’re in prime position to wire up our front-end authorization components. Check back next week to see those updates!

Querying Non-Existent MongoDB Fields

Written by Pete Corey on Sep 5, 2016.

We were recently contacted by one of our readers asking about a security vulnerability in one of their Meteor applications.

They noticed that when they weren’t authenticated, they were able to pull down a large number of documents from a collection through a publication they thought was protected.

The Vulnerability

The publication in question looked something like this:


Meteor.publish("documents", function() {
  return Documents.find({ 
    $or: [
      { userId: this.userId },
      { sharedWith: this.userId }
    ]
  });
});

When an unauthenticated user subscribes to this publication, their userId would be undefined, or null when it’s translated into a MongoDB query.

This means that the query passed into Documents.find would look something like this:


{
  $or: [
    { userId: null },
    { sharedWith: null }
  ]
}

The $or query operator means that if either of these sub-queries match a document, that document will be returned to the client.

The first sub-query, { userId: null }, mostly likely won’t match any documents. It’s unlikely that a Document will be created without a userId, so there will be no Documents with a null or nonexistent userId field.

The second sub-query is more interesting. Due to a quirk with how MongoDB handles null equality queries, the { sharedWith: null } sub-query will return all documents who’s sharedWith field is either null, or unset. This means the query will return all unshared documents.

An un-authenticated user subscribing to the "documents" publication would be able to view huge amounts of private data.

This is a bad thing.

Fixing the Query

There are several ways we can fix this publication. The most straight-forward is to simply deny unauthenticated users access:


Meteor.publish("documents", function() {
  if (!this.userId) {
    throw new Meteor.Error("permission-denied");
  }
  ...
});

Another fix would be to conditionally append the sharedWith sub-query if the user is authenticated:


Meteor.publish("documents", function() {
  let query = {
    $or: [{ userId: this.userId }]
  };
  if (this.userId) {
    query.$or.push({ sharedWith: this.userId });
  }
  return Documents.find(query);
});

This will only add the { sharedWith: this.userId } sub-query if this.userId resolves to a truthy value.

Final Thoughts

This vulnerability represents a larger misunderstanding about MongoDB queries in general. Queries searching for a field equal to null will match on all documents who’s field in question equals null, or who don’t have a value for this field.

For example, this query: { imaginaryField: null } will match on all documents in a collection, unless they have a value in the imaginaryField field that is not equal to null.

This is a very subtle, but very dangerous edge case when it comes to writing MongoDB queries. Be sure to keep it in mind when designing the MongoDB queries used in your Meteor publications and methods.

Phoenix Todos - Static Assets

This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end.

Each commit in the project is represented by a section of the article. Click each section's header to see the commit on Github, or check out the repository and follow along.

Written by Pete Corey on Aug 31, 2016.

Mix Phoenix.New

For this project we’ll be recreating Meteor’s Todos application using a React front-end and an Elixir/Phoenix powered backend. With limited experience with both React and Elixir/Phoenix, this should definitely be an interesting leaning experience.

This will be the largest literate commits project we’ve done to date, and will span several articles published over the upcoming weeks. Be sure to stay tuned for future posts!

This first commit creates a new Phoenix project called phoenix_todos.

Adding mix_test_watch

Moving forward, we’ll be writing tests for the Elixir code we create. Being the good developers that we are, we should test this code.

To make testing a more integrated process, we’ll add the mix_text_watch dependency, which lets us run the mix test.watch command to continuously watch our project for changes and re-run our test suite.

mix.exs

... {:gettext, "~> 0.9"}, - {:cowboy, "~> 1.0"}] + {:cowboy, "~> 1.0"}, + {:mix_test_watch, "~> 0.2", only: :dev}] end

mix.lock

"mime": {:hex, :mime, "1.0.1"}, + "mix_test_watch": {:hex, :mix_test_watch, "0.2.6"}, "phoenix": {:hex, :phoenix, "1.1.6"},

Hello React

Laying the groundwork for using React in a Phoenix project is fairly straight-forward.

We kick things off by installing our necessary NPM dependencies (react, react-dom, and babel-preset-react), and then updating our brunch-config.js to use the required Babel preset, and whitelisting our React NPM modules.

Once that’s finished, we can test the waters by replacing our app.html.eex layout template with a simple React attachment point:


<div id="hello-world"></div>

Finally, we can update our app.js to create and render a HelloWorld component within this new element:


class HelloWorld extends React.Component {
  render() {
    return (<h1>Hello World!</h1>)
  }
}
 
ReactDOM.render(
  <HelloWorld/>,
  document.getElementById("hello-world")
)

For a more detailed rundown of this setup process, be sure to read this fantastic article by Brandon Richey that walks you throw the process step by step.

brunch-config.js

... babel: { + presets: ["es2015", "react"], // Do not use ES6 compiler in vendor code ... npm: { - enabled: true + enabled: true, + whitelist: ["phoenix", "phoenix_html", "react", "react-dom"] }

package.json

{ - "repository": { - }, + "repository": {}, "dependencies": { "babel-brunch": "^6.0.0", + "babel-preset-react": "^6.11.1", "brunch": "^2.0.0", "javascript-brunch": ">= 1.0 < 1.8", + "react": "^15.3.1", + "react-dom": "^15.3.1", "uglify-js-brunch": ">= 1.0 < 1.8"

web/static/js/app.js

-// Brunch automatically concatenates all files in your -// watched paths. Those paths can be configured at -// config.paths.watched in "brunch-config.js". -// -// However, those files will only be executed if -// explicitly imported. The only exception are files -// in vendor, which are never wrapped in imports and -// therefore are always executed. +import React from "react" +import ReactDOM from "react-dom" -// Import dependencies -// -// If you no longer want to use a dependency, remember -// to also remove its path from "config.paths.watched". -import "deps/phoenix_html/web/static/js/phoenix_html" +class HelloWorld extends React.Component { + render() { + return (

Hello World!

) + } +} -// Import local files -// -// Local files can be imported directly using relative -// paths "./socket" or full ones "web/static/js/socket". - -// import socket from "./socket" +ReactDOM.render( + , + document.getElementById("hello-world") +)

web/templates/layout/app.html.eex

<body> - <div class="container"> - <header class="header"> - <nav role="navigation"> - <ul class="nav nav-pills pull-right"> - <li><a href="http://www.phoenixframework.org/docs">Get Started</a></li> - </ul> - </nav> - <span class="logo"></span> - </header> - - <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> - <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> - - <main role="main"> - <%= render @view_module, @view_template, assigns %> - </main> - - </div> <!-- /container --> + <div id="hello-world"></div> <script src="<%= static_path(@conn, "/js/app.js") %>"></script>

Static Assets

Now we can start working in broad strokes. Since this project is a direct clone of the Meteor Todos application, we’re not planning on modifying the application’s stylesheets.

This means that we can wholesale copy the contents of ~/todos/.meteor/.../merged-stylesheets.css into our web/static/css/app.css file.

We can also copy all of the Meteor application’s static assets into our web/static/assets folder, and update our Phoenix Endpoint to make them accessible.

Reloading our application should show us a nice gradient background, and we shouldn’t see any Phoenix.Router.NoRouteError errors when trying to access our static assets.

lib/phoenix_todos/endpoint.ex

... at: "/", from: :phoenix_todos, gzip: false, - only: ~w(css fonts images js favicon.ico robots.txt) + only: ~w(css font js icon favicon.ico favicon.png apple-touch-icon-precomposed.png logo-todos.svg)

Layouts and Containers

Now that we have our basic React functionality set up and our static assets being served, it’s time to start migrating the React components from the Meteor Todos application into our new Phoenix application.

We’ll start this process by changing our app.html.eex file to use the expected "app" ID on its container element.


<div id="app"></div>

Next, we can update our app.js file, removing our HelloWorld component, and replacing it with the setup found in the Todos application. We need to be sure to remove the Meteor.startup callback wrapper, as we won’t be using Meteor:


import { render } from "react-dom";
import { renderRoutes } from "./routes.jsx";

render(renderRoutes(), document.getElementById("app"));

Now we port over the routes.jsx file. We’ll put this directly into our web/static/js folder, next to our app.js file.

We’ll keep things simple at first by only defining routes for the AppContainer and the NotFoundPage.


import AppContainer from './containers/AppContainer.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';

export const renderRoutes = () => (
  <Router history={browserHistory}>
    <Route path="/" component={AppContainer}>
      <Route path="*" component={NotFoundPage}/>
    </Route>
  </Router>
);

The AppContainer in the Meteor application defines a reactive container around an App component. This is very Meteor-specific, so we’ll gut this for now and replace it with a simple container that sets up our initial application state and passes it down to the App component.

Next comes the process of migrating the App component and all of its children components (UserMenu, ListList, ConnectionNotification, etc…).

This migration is fairly painless. We just need to be sure to remove references to Meteor-specific functionality. We’ll replace all of the functionality we remove in future commits.

After all of these changes, we’re greeted with a beautifully styled loading screen when we refresh our application.

package.json

"react": "^15.3.1", + "react-addons-css-transition-group": "^15.3.1", "react-dom": "^15.3.1", + "react-router": "^2.7.0", "uglify-js-brunch": ">= 1.0 < 1.8"

web/static/js/app.js

-import React from "react" -import ReactDOM from "react-dom" +import { render } from "react-dom"; +import { renderRoutes } from "./routes.jsx"; -class HelloWorld extends React.Component { - render() { - return (<h1>Hello World!</h1>) - } -} - -ReactDOM.render( - <HelloWorld/>, - document.getElementById("hello-world") -) +render(renderRoutes(), document.getElementById("app"));

web/static/js/components/ConnectionNotification.jsx

+import React from 'react'; + +const ConnectionNotification = () => ( + <div className="notifications"> + <div className="notification"> + <span className="icon-sync"></span> + <div className="meta"> + <div className="title-notification">Trying to connect</div> + <div className="description">There seems to be a connection issue</div> + </div> + </div> + </div> +); + +export default ConnectionNotification;

web/static/js/components/ListList.jsx

+import React from 'react'; +import { Link } from 'react-router'; + +export default class ListList extends React.Component { + constructor(props) { + super(props); + + this.createNewList = this.createNewList.bind(this); + } + + createNewList() { + const { router } = this.context; + // TODO: Create new list + } + + render() { + const { lists } = this.props; + return ( + <div className="list-todos"> + <a className="link-list-new" onClick={this.createNewList}> + <span className="icon-plus"></span> + New List + </a> + {lists.map(list => ( + <Link + to={`/lists/${ list._id }`} + key={list._id} + title={list.name} + className="list-todo" + activeClassName="active" + > + {list.userId + ? <span className="icon-lock"></span> + : null} + {list.incompleteCount + ? <span className="count-list">{list.incompleteCount}</span> + : null} + {list.name} + </Link> + ))} + </div> + ); + } +} + +ListList.propTypes = { + lists: React.PropTypes.array, +}; + +ListList.contextTypes = { + router: React.PropTypes.object, +};

web/static/js/components/Loading.jsx

+import React from 'react'; + +const Loading = () => ( + <img src="/logo-todos.svg" className="loading-app" /> +); + +export default Loading;

web/static/js/components/Message.jsx

+import React from 'react'; + +const Message = ({ title, subtitle }) => ( + <div className="wrapper-message"> + {title ? <div className="title-message">{title}</div> : null} + {subtitle ? <div className="subtitle-message">{subtitle}</div> : null} + </div> +); + +Message.propTypes = { + title: React.PropTypes.string, + subtitle: React.PropTypes.string, +}; + +export default Message;

web/static/js/components/MobileMenu.jsx

+import React from 'react'; + +function toggleMenu() { + // TODO: Toggle menu +} + +const MobileMenu = () => ( + <div className="nav-group"> + <a href="#" className="nav-item" onClick={toggleMenu}> + <span className="icon-list-unordered" title="Show menu"></span> + </a> + </div> +); + +export default MobileMenu;

web/static/js/components/UserMenu.jsx

+import React from 'react'; +import { Link } from 'react-router'; + +export default class UserMenu extends React.Component { + constructor(props) { + super(props); + this.state = { + open: false, + }; + this.toggle = this.toggle.bind(this); + } + + toggle(e) { + e.stopPropagation(); + this.setState({ + open: !this.state.open, + }); + } + + renderLoggedIn() { + const { open } = this.state; + const { user, logout } = this.props; + const email = user.emails[0].address; + const emailLocalPart = email.substring(0, email.indexOf('@')); + + return ( + <div className="user-menu vertical"> + <a href="#" className="btn-secondary" onClick={this.toggle}> + {open + ? <span className="icon-arrow-up"></span> + : <span className="icon-arrow-down"></span>} + {emailLocalPart} + </a> + {open + ? <a className="btn-secondary" onClick={logout}>Logout</a> + : null} + </div> + ); + } + + renderLoggedOut() { + return ( + <div className="user-menu"> + <Link to="/signin" className="btn-secondary">Sign In</Link> + <Link to="/join" className="btn-secondary">Join</Link> + </div> + ); + } + + render() { + return this.props.user + ? this.renderLoggedIn() + : this.renderLoggedOut(); + } +} + +UserMenu.propTypes = { + user: React.PropTypes.object, + logout: React.PropTypes.func, +};

web/static/js/containers/AppContainer.jsx

+import App from '../layouts/App.jsx'; +import React from 'react'; + +export default class AppContainer extends React.Component { + constructor(props) { + super(props); + this.state = { + user: undefined, + loading: true, + connected: true, + menuOpen: false, + lists: [] + }; + } + + render() { + return (<App {...this.state}/>); + } +};

web/static/js/layouts/App.jsx

+import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; +import UserMenu from '../components/UserMenu.jsx'; +import ListList from '../components/ListList.jsx'; +import ConnectionNotification from '../components/ConnectionNotification.jsx'; +import Loading from '../components/Loading.jsx'; + +const CONNECTION_ISSUE_TIMEOUT = 5000; + +export default class App extends React.Component { + constructor(props) { + super(props); + this.state = { + menuOpen: false, + showConnectionIssue: false, + }; + this.toggleMenu = this.toggleMenu.bind(this); + this.logout = this.logout.bind(this); + } + + componentDidMount() { + setTimeout(() => { + /* eslint-disable react/no-did-mount-set-state */ + this.setState({ showConnectionIssue: true }); + }, CONNECTION_ISSUE_TIMEOUT); + } + + componentWillReceiveProps({ loading, children }) { + // redirect / to a list once lists are ready + if (!loading && !children) { + const list = Lists.findOne(); + this.context.router.replace(`/lists/${ list._id }`{:.language-javascript}); + } + } + + toggleMenu(menuOpen = !Session.get('menuOpen')) { + Session.set({ menuOpen }); + } + + logout() { + Meteor.logout(); + + // if we are on a private list, we'll need to go to a public one + if (this.props.params.id) { + const list = Lists.findOne(this.props.params.id); + if (list.userId) { + const publicList = Lists.findOne({ userId: { $exists: false } }); + this.context.router.push(`/lists/${ publicList._id }`{:.language-javascript}); + } + } + } + + render() { + const { showConnectionIssue } = this.state; + const { + user, + connected, + loading, + lists, + menuOpen, + children, + location, + } = this.props; + + const closeMenu = this.toggleMenu.bind(this, false); + + // clone route components with keys so that they can + // have transitions + const clonedChildren = children && React.cloneElement(children, { + key: location.pathname, + }); + + return ( + <div id="container" className={menuOpen ? 'menu-open' : ''}> + <section id="menu"> + <UserMenu user={user} logout={this.logout}/> + <ListList lists={lists}/> + </section> + {showConnectionIssue && !connected + ? <ConnectionNotification/> + : null} + <div className="content-overlay" onClick={closeMenu}></div> + <div id="content-container"> + <ReactCSSTransitionGroup + transitionName="fade" + transitionEnterTimeout={200} + transitionLeaveTimeout={200} + > + {loading + ? <Loading key="loading"/> + : clonedChildren} + </ReactCSSTransitionGroup> + </div> + </div> + ); + } +} + +App.propTypes = { + user: React.PropTypes.object, // current meteor user + connected: React.PropTypes.bool, // server connection status + loading: React.PropTypes.bool, // subscription status + menuOpen: React.PropTypes.bool, // is side menu open? + lists: React.PropTypes.array, // all lists visible to the current user + children: React.PropTypes.element, // matched child route component + location: React.PropTypes.object, // current router location + params: React.PropTypes.object, // parameters of the current route +}; + +App.contextTypes = { + router: React.PropTypes.object, +};

web/static/js/pages/NotFoundPage.jsx

+import React from 'react'; +import MobileMenu from '../components/MobileMenu.jsx'; +import Message from '../components/Message.jsx'; + +const NotFoundPage = () => ( + <div className="page not-found"> + <nav> + <MobileMenu/> + </nav> + <div className="content-scrollable"> + <Message title="Page not found"/> + </div> + </div> +); + +export default NotFoundPage;

web/static/js/routes.jsx

+import React from 'react'; +import { Router, Route, browserHistory } from 'react-router'; + +// route components +import AppContainer from './containers/AppContainer.jsx'; +import NotFoundPage from './pages/NotFoundPage.jsx'; + +export const renderRoutes = () => ( + <Router history={browserHistory}> + <Route path="/" component={AppContainer}> + <Route path="*" component={NotFoundPage}/> + </Route> + </Router> +);

web/templates/layout/app.html.eex

<body> - <div id="hello-world"></div> + <div id="app"></div> <script src="<%= static_path(@conn, "/js/app.js") %>"></script>

Final Thoughts

This first installment of “Phoenix Todos” mostly consisted of “coding by the numbers”. Migrating over the styles, static assets, and front-end components of our Meteor application into our Phoenix application is tedious work to say the least.

Expect the excitement levels to ramp up in future posts. We’ll be implementing a Phoenix-style authentication system, replacing Meteor publications with Phoenix Channels, and re-implementing Meteor methods as REST endpoints in our Phoenix application.

This conversion process will open some very interesting avenues of comparison and exploration, which I’m eager to dive into.

Be sure to check back next week for Phoenix Todos - Part 2!