Pup

v2.x

RoutingDefining Routes

In your application, routes define what will be rendered or displayed on screen when a user visits a certain URL. For example, if you visit http://myapp.com/login, you're visiting the login route which renders the login page. In Pup, routing is handled using a third-party package called React Router.

React Router is used in Pup because all of its user interface components are written using React. As the name suggests, React Router is designed specifically for defining routes in applications where the user interface is built using React.

All of the routes in Pup are defined in the application's main layout file at /ui/layouts/App/index.js. This component is rendered on client-side startup in /startup/client/index.js.

By default, Pup ships with a few different routes:

Miscellaneous routes

Nondescript routes in Pup. These are the routes that don't fit into a specific group/purpose.

  • / Renders the <Index /> component or "home" page of your application.
  • /terms Renders the <Terms /> component for your application. This is where you can display the "Terms of Service" for your application. Relies on the static pages pattern.
  • /privacy Renders the <Privacy /> component for your application. This is where you can display the "Privacy Policy" for your application. Relies on the static pages pattern.
  • /example-page Renders the <ExamplePage /> component for your application. This is simply an additional placeholder demonstrating how to define a static page. Relies on the static pages pattern.
  • * (no path) Renders the <NotFound /> component for your application. This is displayed whenever a user attempts to access a URL that does not have a matching route defined (e.g., http://localhost:3000/fhqwhgads).

Accounts-related routes

These routes make up the accounts workflows in Pup. Together, these routes allow users to create and manage their accounts.

  • /signup Renders the <Signup /> component for your application. This is where users can create a new account.
  • /login Renders the <Login /> component for your application. This is where existing users can login to their account.
  • /profile Renders the <Profile /> component for your application. This is where logged-in users can change their first name, last name, email address, and password for their account.
  • /recover-password Renders the <RecoverPassword /> component for your application. This is where users can request a password reset if they've forgotten their password.
  • /reset-password/:token Renders the <ResetPassword /> component for your application. This is where users can reset their password, using a unique token that's emailed to them when they request the reset.
  • /logout Renders the <Logout /> component for your application. This is displayed after a user logs out and is useful for promoting your product on social media or new features you've just released.

Example Routes

The following routes are added to Pup to showcase creating a "CRUD" (create, read, update, delete) feature in your application. Feel free to use these as a template for your own features.

  • /documents Renders the <Documents /> component for your application. For logged-in users, displays that user's list of documents that they've created.
  • /documents/:_id Renders the <ViewDocument /> component for your application. For logged-in users, displays a single document corresponding to the :_id parameter in the route. :_id refers to the literal _id value of the document in the Documents collection.
  • /documents/:_id/edit Renders the <EditDocument /> component for your application. For logged-in users, displays a form for making changes to an existing document, corresponding to the :_id parameter in the route. :_id refers to the literal _id value of the document in the Documents collection.
/ui/layouts/App/index.js
/* eslint-disable jsx-a11y/no-href */

import React from 'react';
import PropTypes from 'prop-types';
import { Switch, Route } from 'react-router-dom';
import { Grid } from 'react-bootstrap';
import { Meteor } from 'meteor/meteor';
import { Roles } from 'meteor/alanning:roles';

import Navigation from '../../components/Navigation';

import Authenticated from '../../components/Authenticated';
import Authorized from '../../components/Authorized';
import Public from '../../components/Public';

import Index from '../../pages/Index';

import Documents from '../../pages/Documents';
import ViewDocument from '../../pages/ViewDocument';
import EditDocument from '../../pages/EditDocument';

import Profile from '../../pages/Profile';
import Signup from '../../pages/Signup';
import Login from '../../pages/Login';
import Logout from '../../pages/Logout';

import VerifyEmail from '../../pages/VerifyEmail';
import RecoverPassword from '../../pages/RecoverPassword';
import ResetPassword from '../../pages/ResetPassword';

import AdminUsers from '../../pages/AdminUsers';
import AdminUser from '../../pages/AdminUser';
import AdminUserSettings from '../../pages/AdminUserSettings';

import NotFound from '../../pages/NotFound';
import Footer from '../../components/Footer';

import Terms from '../../pages/Terms';
import Privacy from '../../pages/Privacy';
import ExamplePage from '../../pages/ExamplePage';

import VerifyEmailAlert from '../../components/VerifyEmailAlert';
import GDPRConsentModal from '../../components/GDPRConsentModal';

import withTrackerSsr from '../../../modules/withTrackerSsr';
import getUserName from '../../../modules/getUserName';

import Styles from './styles';

class App extends React.Component {
  state = { ready: false, afterLoginPath: null };

  componentDidMount() {
    this.setPageReady();
  }

  setPageReady = () => {
    this.setState({ ready: true });
  };

  setAfterLoginPath = (afterLoginPath) => {
    this.setState({ afterLoginPath });
  };

  render() {
    const { props, state, setAfterLoginPath } = this;
    return (
      <Styles.App ready={this.state.ready} loading={props.loading}>
        {props.authenticated && (
          <VerifyEmailAlert
            userId={props.userId}
            emailVerified={props.emailVerified}
            emailAddress={props.emailAddress}
          />
        )}
        {props.authenticated && <GDPRConsentModal userId={props.userId} />}
        <Navigation {...props} {...state} />
        <Grid>
          <Switch>
            <Route exact name="index" path="/" component={Index} />

            <Authenticated
              exact
              path="/documents"
              component={Documents}
              setAfterLoginPath={setAfterLoginPath}
              {...props}
              {...state}
            />
            <Route exact path="/documents/:_id" component={ViewDocument} />
            <Authenticated
              exact
              path="/documents/:_id/edit"
              component={EditDocument}
              setAfterLoginPath={setAfterLoginPath}
              {...props}
              {...state}
            />

            <Authenticated
              exact
              path="/profile"
              component={Profile}
              setAfterLoginPath={setAfterLoginPath}
              {...props}
              {...state}
            />
            <Public path="/signup" component={Signup} {...props} {...state} />
            <Public path="/login" component={Login} {...props} {...state} />
            <Route
              path="/logout"
              render={(routeProps) => (
                <Logout {...routeProps} setAfterLoginPath={setAfterLoginPath} />
              )}
              {...props}
              {...state}
            />

            <Route name="verify-email" path="/verify-email/:token" component={VerifyEmail} />
            <Route name="recover-password" path="/recover-password" component={RecoverPassword} />
            <Route name="reset-password" path="/reset-password/:token" component={ResetPassword} />

            <Route name="terms" path="/terms" component={Terms} />
            <Route name="privacy" path="/privacy" component={Privacy} />
            <Route name="examplePage" path="/example-page" component={ExamplePage} />

            <Authorized
              exact
              allowedRoles={['admin']}
              path="/admin/users"
              pathAfterFailure="/"
              component={AdminUsers}
              setAfterLoginPath={setAfterLoginPath}
              {...props}
              {...state}
            />
            <Authorized
              exact
              allowedRoles={['admin']}
              path="/admin/users/settings"
              pathAfterFailure="/"
              component={AdminUserSettings}
              setAfterLoginPath={setAfterLoginPath}
              {...props}
              {...state}
            />
            <Authorized
              exact
              allowedRoles={['admin']}
              path="/admin/users/:_id"
              pathAfterFailure="/"
              component={AdminUser}
              setAfterLoginPath={setAfterLoginPath}
              {...props}
              {...state}
            />

            <Route component={NotFound} />
          </Switch>
        </Grid>
        <Footer />
      </Styles.App>
    );
  }
}

App.defaultProps = {
  loading: true,
  userId: '',
  emailAddress: '',
  emailVerified: false,
  authenticated: false,
};

App.propTypes = {
  loading: PropTypes.bool,
  userId: PropTypes.string,
  emailAddress: PropTypes.string,
  emailVerified: PropTypes.bool,
  authenticated: PropTypes.bool,
};

export default withTrackerSsr(() => {
  const app = Meteor.subscribe('app');
  const loggingIn = Meteor.loggingIn();
  const user = Meteor.user();
  const userId = Meteor.userId();
  const loading = !app.ready() && !Roles.subscription.ready();
  const name = user && user.profile && user.profile.name && getUserName(user.profile.name);
  const emailAddress = user && user.emails && user.emails[0].address;

  return {
    loading,
    loggingIn,
    authenticated: !loggingIn && !!userId,
    name: name || emailAddress,
    roles: Roles.getRolesForUser(userId),
    userId,
    emailAddress,
    emailVerified: user && user.emails ? user.emails[0] && user.emails[0].verified : true,
  };
})(App);

Authenticated, Authorized, and Public Routes

You may have noticed that in the list of routes above, a few different components are in use for rendering routes: <Route />, <Authenticated />, <Authorized />, and <Public />. The first, <Route />, is a component imported directly from the react-router-dom package. This simply creates a route in the application at the URL specified in the path prop and renders the component specified in the component prop.

The other three components, <Authenticated />, <Authorized />, and <Public />, are Pup-specific components used to create special types of routes that render the specified component prop depending on the user's authentication (logged-in/logged-out) status.

Authenticated Routes

As the name implies, routes created using the <Authenticated /> component are only intended to be accessed by authenticated or logged-in users. To handle the authentication of a user, the component takes in two special props loggingIn and authenticated which are passed to the component via Pup's <App /> component.

The <App /> component is wrapped by a data container which passes down the value of Meteor.loggingIn() independently, and authenticated as a combination of checking if Meteor is logging in and if a userId exists !loggingIn && !!userId.

Initial page is tracked

Good news! If a user attempts to access an authenticated page while they're logged out, Pup will keep track of this and redirect the user back to that page after they've logged in.

Inside of the <Authenticated /> component, if the passed loggingIn prop is true, a blank <div></div> element is rendered on screen until loggingIn is false, meaning, we have a user that we can authenticate with. If we do have a user, we go ahead and render the passed component prop using the React.createElement() method.

If we do not have a user and we're not logging in, we consider this user to be "unauthenticated" and redirect them away from the page they're accessing. By default this redirect is to the /login route in the application but can be customized to any page you'd like.

Notice that what we're returning from our <Authenticated /> component is just the <Route /> component from the react-router-dom package. What's ultimately happening here is that the <Route /> component is returned and then rendered within our routes list, at which point React Router takes over.

Authorized Routes

As the name implies, routes created using the <Authorized /> component are only intended to be accessed by authorized or users in a specific role (like admin). To handle the authorization of a user, the component takes in a special prop allowedRoles which expects an array of roles to be passed (e.g., ['admin', 'manager']) specifying the roles the current user must have in order to access that route.

Inside of the <Authorized /> component, a function is defined checkIfAuthorized which attempts to verify whether or not the current user is allowed to access the route defined using the component. Due to Meteor's reactivity, the checkIfAuthorized method is called twice: once on componentDidMount(), meaning, when the <Authorized /> component is mounted and on componentDidUpdate(), whenever the props passed to the <Authorized /> component are updated (in this case, when the user's logged in state or roles change).

If the user's roles match those passed in the allowedRoles array, the page is rendered for the user as expected. If the user is not in one of the necessary roles, they're redirected to the path specified by the pathAfterFailure prop on the component. If this prop isn't provided, the user is redirected to the / or index page of the app.

Public Routes

The <Public /> component is designed as a wrapper component around pages that are intended for the public only. By wrapping a component in the <Public > component, the intent is to redirect an authenticated or logged-in user away from this page (e.g., a logged in user has no purpose accessing the /signup page). If we look at the code for the <Public /> component, it follows the exact same pattern as the <Authenticated /> component described above, however, running its authentication check in reverse.

/ui/components/Authenticated/index.js
import React from 'react';
import PropTypes from 'prop-types';
import { Route, Redirect } from 'react-router-dom';
import { Meteor } from 'meteor/meteor';

class Authenticated extends React.Component {
  componentWillMount() {
    if (Meteor.isClient)
      this.props.setAfterLoginPath(`${window.location.pathname}${window.location.search}`);
  }

  render() {
    const { loggingIn, authenticated, component, path, exact, ...rest } = this.props;

    return (
      <Route
        path={path}
        exact={exact}
        render={(props) =>
          authenticated ? (
            React.createElement(component, {
              ...props,
              ...rest,
              loggingIn,
              authenticated,
            })
          ) : (
            <Redirect to="/login" />
          )
        }
      />
    );
  }
}

Authenticated.defaultProps = {
  loggingIn: false,
  path: '',
  exact: false,
};

Authenticated.propTypes = {
  loggingIn: PropTypes.bool,
  authenticated: PropTypes.bool.isRequired,
  component: PropTypes.func.isRequired,
  setAfterLoginPath: PropTypes.func.isRequired,
  path: PropTypes.string,
  exact: PropTypes.bool,
};

export default Authenticated;
/ui/components/Authorized/index.js
import React from 'react';
import PropTypes from 'prop-types';
import { Route } from 'react-router-dom';
import { withRouter } from 'react-router';
import { Roles } from 'meteor/alanning:roles';
import { Meteor } from 'meteor/meteor';
import { withTracker } from 'meteor/react-meteor-data';

class Authorized extends React.Component {
  state = { authorized: false };

  componentDidMount() {
    this.checkIfAuthorized();
  }

  componentDidUpdate() {
    this.checkIfAuthorized();
  }

  checkIfAuthorized = () => {
    const { loading, userId, userRoles, userIsInRoles, pathAfterFailure } = this.props;

    if (!userId) this.props.history.push(pathAfterFailure || '/');

    if (!loading && userRoles.length > 0) {
      if (!userIsInRoles) {
        this.props.history.push(pathAfterFailure || '/');
      } else {
        // Check to see if authorized is still false before setting. This prevents an infinite loop
        // when this is used within componentDidUpdate.
        if (!this.state.authorized) this.setState({ authorized: true }); // eslint-disable-line
      }
    }
  };

  render() {
    const { component, path, exact, ...rest } = this.props;

    return this.state.authorized ? (
      <Route
        path={path}
        exact={exact}
        render={(props) => React.createElement(component, { ...rest, ...props })}
      />
    ) : (
      <div />
    );
  }
}

Authorized.defaultProps = {
  allowedGroup: null,
  userId: null,
  exact: false,
  userRoles: [],
  userIsInRoles: false,
  pathAfterFailure: '/login',
};

Authorized.propTypes = {
  loading: PropTypes.bool.isRequired,
  allowedRoles: PropTypes.array.isRequired,
  allowedGroup: PropTypes.string,
  userId: PropTypes.string,
  component: PropTypes.func.isRequired,
  path: PropTypes.string.isRequired,
  exact: PropTypes.bool,
  history: PropTypes.object.isRequired,
  userRoles: PropTypes.array,
  userIsInRoles: PropTypes.bool,
  pathAfterFailure: PropTypes.string,
};

export default withRouter(
  withTracker(
    ({ allowedRoles, allowedGroup }) =>
    // eslint-disable-line
      Meteor.isClient
        ? {
            loading: Meteor.isClient ? !Roles.subscription.ready() : true,
            userId: Meteor.userId(),
            userRoles: Roles.getRolesForUser(Meteor.userId()),
            userIsInRoles: Roles.userIsInRole(Meteor.userId(), allowedRoles, allowedGroup),
          }
        : {},
  )(Authorized),
);
/ui/components/Public/index.js
import React from 'react';
import PropTypes from 'prop-types';
import { Route, Redirect } from 'react-router-dom';

const Public = ({ loggingIn, authenticated, afterLoginPath, component, path, exact, ...rest }) => (
  <Route
    path={path}
    exact={exact}
    render={(props) =>
      !authenticated ? (
        React.createElement(component, {
          ...props,
          ...rest,
          loggingIn,
          authenticated,
        })
      ) : (
        <Redirect to={afterLoginPath || '/documents'} />
      )
    }
  />
);

Public.defaultProps = {
  loggingIn: false,
  path: '',
  exact: false,
  afterLoginPath: null,
};

Public.propTypes = {
  loggingIn: PropTypes.bool,
  authenticated: PropTypes.bool.isRequired,
  component: PropTypes.func.isRequired,
  afterLoginPath: PropTypes.string,
  path: PropTypes.string,
  exact: PropTypes.bool,
};

export default Public;