This article is the third installment of a tutorial series focused on how to create a full-stack application using Flask, React/Redux and Stream. In this article, we are going to finish implementing our authentication flow as well as integrating a basic profile component into our app. Be sure to check out the repo to follow along!
Getting Started
Last week we started working on the authentication flow by creating a registration component as well as working with React-Redux in managing state within our application. This week, we are going to create a login portal and 'settings' page to edit a user's information, such as their username, real name, and bio. Once we finish that, we set up a user profile page, as well as authenticated and unauthenticated routing using a concept known as Higher-Order Components. Finally, we create a custom 404 page to render if a user navigates to a page that isn't found on our site.
In and Out
Similar to our registration component, we start with constructing the server request for a user to sign in and out (in app/static/js/agent.jsx
).
#... const Auth = { #... login: (email, password) => agent.post('/auth/login', { email, password }), logout: () => agent.get('/auth/logout') #...
We wait to integrate the logout functionality to the settings component because that difference is more aesthetic than functional, so it makes more conceptual sense to complete these two together.
Reduced
Next, we need to update our Auth and Common reducers to handle dispatched events to change state. We will be adding our actions to the Auth reducer first (in app/static/js/reducers/auth.jsx
).
export default (state = {}, action) => { switch (action.type) { case 'LOGIN': case 'REGISTER': return { ...state, inProgress: false, errors: action.error ? action.payload.errors : null }; case 'LOGIN_PAGE_UNLOADED': case 'REGISTER_PAGE_UNLOADED': return {}; case 'ASYNC_START': if (action.subtype ==='LOGIN' || action.subtype === 'REGISTER') { return { ...state, inProgress: true }; } break; case 'UPDATE_FIELD_AUTH': return { ...state, [action.key]: action.value }; default: return state; } return state; }
As you might expect, the login event is handled at the same time as the event for registration as they fulfill very similar functionality in the client.
After, we update our Common reducer to update the currentUser property for our app as well as handling any redirections (in app/static/js/reducers/common.jsx
).
const defaultState = { appName: 'Offbrand' }; export default (state = defaultState, action) => { switch(action.type) { case 'APP_LOAD': return { ...state, appLoaded: true, currentUser: action.payload ? action.payload.user : null }; case 'REDIRECT': return {...state, redirectTo: null}; case 'LOGOUT': return { ...state, redirectTo: '/', currentUser: null}; case 'SETTINGS_SAVED': return { ...state, redirectTo: action.error ? null : '/', currentUser: action.error ? null : action.payload.user }; case 'LOGIN': case 'REGISTER': return { ...state, redirectTo: action.error ? null : '/', currentUser: action.error ? null : action.payload.user, }; case 'LOGIN_PAGE_UNLOADED': return { ...state }; default: return state; } }
You'll notice the logout action is similar to that for login, but in contrast, the action erases any currentUser information from the client-side of the application.
Middle-Out
Now that our reducers are taken care of, we need to adjust our middleware to handle the local storage of the currentUser object in response to the login and logout actions (in app/static/js/middleware.jsx
).
const promiseMiddleware = store => next => action => { if (isPromise(action.payload)) { store.dispatch({type: 'ASYNC_START', subtype: action.type}); action.payload.then( res => { action.payload = res; store.dispatch(action); }, error => { action.error = true; action.payload = error.response.body; store.dispatch(action); } ); return; } next(action); }; function isPromise(v) { return v && typeof v.then === 'function'; } const localStorageMiddleware = store => next => action => { if (action.type === 'REGISTER' || action.type === 'LOGIN') { if (!action.error) { window.localStorage.setItem('currentUser', action.payload.user); store.redirectTo = '/' } } else if (action.type === 'LOGOUT') { window.localStorage.setItem('currentUser', ''); } next(action); }; export { localStorageMiddleware, promiseMiddleware }
In Component
With the completion of the "plumbing" of this page regarding requests and state, we can start creating the component itself. Once again, the page bears a striking resemblance to our registration page, handling a form and submitting it with the onSubmit function. We want to link our registration form to allow new users the opportunity to switch between the two components quickly. Additionally, as the state is updated to hold the input from our form, we want to clear those fields when a user navigates away from the page. The onUnload function clears that state when the user navigates from the page. We also include theListErrors component to return any issues that occur during server-side validation (in app/static/js/components/auth/Login.jsx
).
import React, { Component } from 'react'; import { connect } from 'react-redux' import { Link } from 'react-router-dom' import agent from '../../agent' import ListErrors from '../ListErrors'; const mapStateToProps = state => ({ ...state.auth }); const mapDispatchToProps = dispatch => ({ onChangeEmail: value => dispatch({ type: 'UPDATE_FIELD_AUTH', key: 'email', value}), onChangePassword: value => dispatch({ type: 'UPDATE_FIELD_AUTH', key: 'password', value}), onSubmit: (email, password) => dispatch({ type: 'LOGIN', payload: agent.Auth.login(email, password)}), onUnload: () => dispatch({ type: 'LOGIN_PAGE_UNLOADED' }) }); class Login extends Component { constructor() { super(); this.changeEmail = event => this.props.onChangeEmail(event.target.value); this.changePassword = event => this.props.onChangePassword(event.target.value); this.submitForm = (email, password) => event => { event.preventDefault(); this.props.onSubmit(email, password); }; } componentWillUnmount() { this.props.onUnload(); } render() { const { email, password } = this.props; return ( <div className="auth-page"> <div className="container page"> <div className="row"> <div className="col-md-6 offset-md-3 col-xs-12"> <h1 className="text-xs-center"> Sign In </h1> <p className="text-xs-center"> <Link to="register"> Need an account? </Link> </p> <ListErrors errors={this.props.errors} /> <form onSubmit={this.submitForm(email, password)}> <fieldset> <fieldset className="form-group"> <input className="form-control form-control-lg" type="email" placeholder="Email" value={this.props.email || ""} onChange={this.changeEmail} /> </fieldset> <fieldset className="form-group"> <input className="form-control form-control-lg" type="password" placeholder="Password" value={this.props.password || ""} onChange={this.changePassword}/> </fieldset> <button className="btn btn-lg btn-primary pull-xs-right" type="submit" disabled={this.props.inProgress}> Sign in </button> </fieldset> </form> </div> </div> </div> </div> ) } } export default connect(mapStateToProps, mapDispatchToProps)(Login);
Routes on Routes
Once we have the page created, we need to create a route in our router for it to be accessed. As with all our routes, this is done in the AppRouter component (in app/static/js/AppRouter.jsx
).
#... import Login from './components/auth/Login.jsx' #... class AppRouter extends Component { #... render() { #... <Switch> <Route exact path="/" component={() => <Home currentUser={this.props.currentUser} />} /> <Route path="/register" component={Register} /> <Route path="/login" component={Login} /> </Switch> #...
Form Time
Now, our client-side is finished in regards to handling login and logout. We can now head to the server-side code to provide the endpoints and forms for the request. We'll start by defining the form (in app/auth/forms.py
).
#... class LoginForm(FlaskForm): class Meta: csrf = False email = StringField('Email', validators=[DataRequired(), Length(1, 64), Email()]) password = PasswordField('Password', validators=[DataRequired()])
Authentic Views
Next, we need to build the views to process the request. After importing the form we created to the views file, we can use it in the login endpoint and validate the request information. This form returns a JSON list of each error to display in the ListErrors component. If everything is copacetic, the user is logged in with flask-login, which creates an authentication cookie, and a user JSON object is returned to the client. I've also included a logout view to destroy that cookie and finalize the erasure of any remnants of the authenticated user session when requested (in app/auth/views.py
).
#... from flask_login import login_user, login_required, \ current_user, logout_user from .forms import RegistrationForm, LoginForm from .errors import forbidden #... @auth.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data.lower()).first() if user is not None and user.verify_password(form.password.data): login_user(user, False) return {'user': current_user.to_json()}, 200 return forbidden('Invalid Credentials') else: return {'errors': {fieldName.title(): errorMessages for fieldName, errorMessages in form.errors.items()}}, 400 @auth.route('/logout') @login_required def logout(): logout_user() return {"success": 200}
After that, our login portion is completed, and we can move onto the settings.
Next Steps
Again, we start from the agent to perform our requests. This creates a form object to send with our request including the username, name and about_me fields (in app/static/js/agents.jsx
).
#... const Auth = { #... save: (username, name, about_me) => agent.post('/edit-profile', { username, name, about_me }) #...
Settings The Settings
Now we need to create a new reducer to handle actions regarding state for our settings component (in app/static/js/reducers/settings.jsx
).
export default (state = {}, action) => { switch (action.type) { case 'SETTINGS_SAVED': return { ...state, inProgress: false, errors: action.error ? action.payload.errors: null }; case 'ASYNC_START': return { ...state, inProgress: true }; default: return state; } }
Update Store
As we have created a new reducer, we need to include it in our combineReducer function within the store (in app/static/js/store.jsx
).
#... import settings from './reducers/settings'; #... const reducer = combineReducers({ auth, common, settings, router: routerReducer }); #...
One Component At A Time
Our settings component deals with state differently than in our previous components. Since any of the variables we change will invariably (get it?) change the state of the currentUser object, we need to handle these changes carefully. Updating state directly by overwriting it is a big no-no, so we create a brand new object with the new state given on submit, with unchanged values given from the pre-existing state. While this may seem confusing, we are simply creating a brand new state object based on the new information with fallback values for the old ones if it hasn't changed. This new state object is then set for the component and application (in app/static/js/components/auth/Settings.jsx
).
import React, { Component } from 'react' import agent from '../../agent' import { connect } from 'react-redux' import ListErrors from '../ListErrors' const mapStateToProps = state => ({ ...state.settings, currentUser: state.common.currentUser }); const mapDispatchToProps = dispatch => ({ onClickLogout: () => dispatch({ type: 'LOGOUT', payload: agent.Auth.logout() }), onSubmitForm: user => dispatch({ type: 'SETTINGS_SAVED', payload: agent.Auth.save( user.username, user.name, user.about_me ) }), onUnload: () => dispatch({ type: 'SETTINGS_PAGE_UNLOADED' }) }); class SettingsForm extends Component { constructor() { super(); this.state = { username: '', name: '', about_me: '', }; this.updateState = field => ev => { const state = this.state; const newState = Object.assign({}, state, { [field]: ev.target.value }); this.setState(newState); }; this.submitForm = ev => { ev.preventDefault(); const user = Object.assign({}, this.state); this.props.onSubmitForm(user) }; } componentWillMount() { if (this.props.currentUser) { Object.assign(this.state, { username: this.props.currentUser.username, name: this.props.currentUser.name, about_me: this.props.currentUser.about_me }); } } componentWillReceiveProps(nextProps) { if (nextProps.currentUser) { this.setState(Object.assign({}, this.state, { username: nextProps.currentUser.username, name: nextProps.currentUser.name, about_me: nextProps.currentUser.about_me, })); } } render() { return ( <form onSubmit={this.submitForm}> <fieldset> <fieldset className="form-group"> <input className="form-control form-control-lg" type="text" placeholder="Username" value={this.state.username} onChange={this.updateState('username')} /> </fieldset> <fieldset className="form-group"> <input className="form-control form-control-lg" type="text" placeholder="Name" value={this.state.name || ''} onChange={this.updateState('name')} /> </fieldset> <fieldset className="form-group"> <textarea className="form-control form-control-lg" rows="8" placeholder="Short bio about you" value={this.state.about_me || ''} onChange={this.updateState('about_me')} /> </fieldset> <button className="btn btn-lg btn-primary pull-xs-right" type="submit" disabled={this.state.inProgress}> Update Settings </button> </fieldset> </form> ); } } class Settings extends React.Component { render() { return ( <div className="settings-page"> <div className="container page"> <div className="row"> <div className="col-md-6 offset-md-3 col-xs-12"> <h1 className="text-xs-center">Your Settings</h1> <ListErrors errors={this.props.errors}></ListErrors> <SettingsForm currentUser={this.props.currentUser} onSubmitForm={this.props.onSubmitForm} /> <hr /> <button className="btn btn-outline-danger" onClick={this.props.onClickLogout}> Or click here to logout. </button> </div> </div> </div> </div> ); } } export default connect(mapStateToProps, mapDispatchToProps)(Settings);
App Router
Now that our settings component is finished, we once again have to integrate it with our application router before we head back to complete the server code (in app/static/js/AppRouter.jsx
)
#... import Settings from './components/auth/Settings' #... class AppRouter extends Component { #... render() { return ( #... <Switch> <Route exact path="/" component={() => <Home currentUser={this.props.currentUser} />} /> <Route path="/register" component={Register} /> <Route path="/login" component={Login} /> <Route path="/settings" component={Settings} /> </Switch> #...
Back To The Server
As we have a new form-based request, we need to create another form class to handle the validation of the fields being passed (in app/main/forms.py
).
from flask_login import current_user from flask_wtf import FlaskForm from wtforms import StringField, TextAreaField from wtforms.validators import Length, Regexp from wtforms import ValidationError from ..models import User class EditProfileForm(FlaskForm): class Meta: csrf = False name = StringField('name', validators=[Length(0, 64)]) username = StringField('username', validators=[Length(1, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, 'Usernames must have only letters, numbers, dots or ' 'underscores')]) about_me = TextAreaField('about_me') def validate_username(self, field): if field.data and field.data != current_user.username: if User.query.filter_by(username=field.data).first(): raise ValidationError('Username already in use.')
As we are potentially updating the username field, which is a unique entity in our database, we also need to perform a quick check to ensure it hasn't already been taken.
Main Views
Next, we create our endpoint to handle the settings request (in app/main/views.py
)
#... from .forms import EditProfileForm from .. import db #... @main.route('/edit-profile', methods=['POST']) @login_required def edit_profile(): form = EditProfileForm() if form.validate_on_submit(): if form.name.data: current_user.name = form.name.data if form.username.data: current_user.username = form.username.data if form.about_me.data: current_user.about_me = form.about_me.data db.session.add(current_user._get_current_object()) db.session.commit() return jsonify({'user': current_user.to_json()}) else: return {'errors': {fieldName.title(): errorMessages for fieldName, errorMessages in form.errors.items()}}, 400
As some users only update select fields at any given time, we can't give an absolute "DataRequired" validation to our form; therefore, we need to handle it conditionally within the route itself. Also, after we are finished, we need to retrieve the newly updated user information so that it can be returned back to the client.
Main Errors
Since we are also starting to build out our endpoints in the main section of our app, it would also be prudent to build out a similar error folder for these requests (in app/main/errors.py
).
from flask import jsonify from . import main @main.app_errorhandler(403) def forbidden(e): response = jsonify({'errors': {'Forbidden:': [e]}}) response.status_code = 403 return response @main.app_errorhandler(404) def page_not_found(e): response = jsonify({'errors': {'Page Not Found': [e]}}) response.status_code = 404 return response @main.app_errorhandler(500) def internal_server_error(e): response = jsonify({'errors': {'Internal Server Error': [e]}}) response.status_code = 500 return response
Some of you may have noticed that I placed the settings updates within the 'main' folder on the server while in the 'auth' folder in the client. To avoid a lengthy diatribe on naming conventions, it was because I prefer to keep routes that specifically handle authentication together on the server, while keeping the the client-side for anything that handles client identity.
Sanity Check #1
This concludes creating authentication components; therefore, now is a good time to stop and check that everything is running properly. When running the React app with npm run watch and the Flask server with flask run, you should see something similar when you navigate to the settings page after registering.
A Quick Profile
At this point, we have finished our authentication routes for our client. However, we haven't done anything with this information other than displaying a name/username on the home page and a gravatar image in the navbar. As with our last version, we want to have user profile pages for other users to see and, eventually, follow.
So Many Agents
We start by creating a request to be sent to the server with the username of the user that we are looking for. We have already created the skeleton of what the request on the client will look like within the authenticated header section, with a username preceded by an '@' symbol. We will create this new request in our agent file (in app/static/js/agent.jsx
).
#... const Profile = { get: username => agent.get(`/user/${username}`), }; export default { Auth, Profile }
Profile Reducer
Next up is to create a reducer to handle dispatched events. This step takes the payload of the request that we have made and return the 'profile' of that user as a state (in app/static/js/reducers/profile.jsx
).
export default (state = {}, action) => { switch (action.type) { case 'PROFILE_PAGE_LOADED': return { ...action.payload.profile }; case 'PROFILE_PAGE_UNLOADED': return {}; default: return state; } };
Update store
As we have created a new reducer, we will once again need to integrate it into our store (in app/static/js/store.jsx
).
#... import profile from './reducers/profile'; #... const reducer = combineReducers({ auth, common, profile, settings, router: routerReducer }); #...
Profile Component
Now we can create our user profile component. This component is slightly more elaborate than the previous ones that we have made, but we keep the content as bare-bones as possible to let us quickly walk through the process (in app/static/js/components/Profile.jsx
).
import React, { Component } from 'react'; import { Link } from 'react-router-dom'; import agent from '../agent'; import { connect } from 'react-redux'; const mapStateToProps = state => ({ currentUser: state.common.currentUser, profile: state.profile }); const mapDispatchToProps = dispatch => ({ onLoad: payload => dispatch({ type: 'PROFILE_PAGE_LOADED', payload }), onUnload: () => dispatch({ type: 'PROFILE_PAGE_UNLOADED'}) }); const EditProfileSettings = props => { if (props.isUser) { return ( <Link to="/settings" className="btn btn-sm btn-outline-secondary action-btn"> Edit Profile Settings </Link> ); } return null; }; class Profile extends Component { componentWillMount() { this.props.onLoad(agent.Profile.get(this.props.match.params.username)) }; componentWillUnmount() { this.props.onUnload(); } render() { const profile = this.props.profile; if (!profile) { return null; } const isUser = this.props.currentUser && this.props.profile.username === this.props.currentUser.username; return ( <div className="profile-page"> <div className="user-info"> <div className="container"> <div className="col-xs-12 col-md-10 offset-md-1"> <img src={profile.image} className="user-img" alt={profile.username}/> <h4>{profile.name ? profile.name : profile.username}</h4> <p>{profile.about_me}</p> <EditProfileSettings isUser={isUser}/> </div> </div> </div> </div> ); } } export default connect(mapStateToProps, mapDispatchToProps)(Profile); export { Profile, mapStateToProps };
The first element that we need to create is the ComponentWillMount function. It uses our agent.Profile.get() function that we recently defined fetches the user profile of the page we are currently on. It uses the match parameters property for the username that is passed through the route to define which user we are looking for. We define this property through the route page later. After that, the isUser function checks to see whether or not the user that we are requesting is identical to the currentUser, which conditionally renders an Edit Profile Settings button that links to our settings route.
Routing
Next, we import the profile component and set up its path. We define the parameter for username after the colon within the Route (in app/static/js/AppRouter.jsx
).
#... import Profile from './components/User'; #... class AppRouter extends Component { #... render() { return ( #... <Switch> #... <Route path="/@:username" component={Profile} </Switch> #...
Main Views
Our next step is to create the view to return our user profile. As our to_json() class method returns sensitive information like an email address and a user's Stream access token, we want to remove those before we return an entry (in app/main/views.py
).
#... from .errors import page_not_found from ..models import User #... @main.route('/user/<username>', methods=['GET']) @login_required def get_user(username): user = User.query.filter_by(username=username).first() if not user: return page_not_found user = user.to_json() user.pop('stream_token') user.pop('email') return {'profile': user} #...
We can also use the custom errors we created in the errors.py file to return a more accurate error message to a requestor.
Conditional Routes
Now that we have a user profile, authentication methods for registration, login, and editing a user's settings, we still have a bit of a problem. First and foremost, we have no way of routing users based on their authentication state. For example, if an unauthenticated user goes to a profile page, they will break the isUser function. We can correct this issue with Higher-Order Components (HOC).
Authenticated Routes
HOCs wrap a given function or class with another function or class, creating the ability to provide conditional routing and rendering for similarly structured components that wish to accomplish the same things. This helps to avoid code repetition (DRY!). We create a HOC to wrap routes that are specifically for users that have already authenticated (in `app/static/js/AuthedRoute.jsx).
import React, { Component } from 'react'; import { Route, Redirect, withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; const mapStateToProps = (state) => ({ currentUser: state.common.currentUser }); class AuthedRoute extends Component { render() { const { component, ...rest } = this.props; const Component = component; return ( <Route {...rest} render={(props) => { if (!localStorage.getItem('currentUser')) { if (this.props.redirect) { return <Redirect to="/register" />; } else { return <div />; } } else if (!this.props.currentUser) { console.log(1); return <div />; } else { return <Component {...props} />; } }} /> ); } } AuthedRoute.defaultProps = { redirect: true, }; export default withRouter(connect(mapStateToProps)(AuthedRoute));
Unauthenticated Routes
Next, we do the same thing for components that should be only viewed by users that are unauthenticated, like login and registration (in `app/static/js/UnauthedRoute.jsx).
import { Redirect, Route, withRouter } from 'react-router-dom'; import React from 'react'; const UnauthedRoute = ({ component: Component, ...rest}) => { return( <Route {...rest} render={(props) => { if (!window.localStorage.getItem('currentUser')) { return <Component {...props} />; } else if (rest.redirect) { return <Redirect to='/' />; } }} /> ); }; UnauthedRoute.defaultProps = { redirect: true, }; export default withRouter(UnauthedRoute)
Tying It All Together
Finally, we replace our routes in the AppRouter to use these Authed and Unauthed routes instead of the lower-order components (in app/static/js/AppRouter.jsx').
#... import AuthedRoute from './AuthedRoute'; import UnauthedRoute from './UnauthedRoute'; #... class AppRouter extends Component { #... render() { return ( #... <Switch> <Route exact path="/" component={() => <Home currentUser={this.props.currentUser} />} /> <UnauthedRoute path="/register" component={Register} /> <UnauthedRoute path="/login" component={Login} /> <AuthedRoute path="/settings" component={Settings} /> <AuthedRoute path="/@:username" component={Profile} /> </Switch> #...
Not Found
Last but not least, if a user navigates to a route that doesn't exist on our application, they are given an ugly 'not found' response. We can create our own custom 404 page to replace it (in app/static/js/components/NotFound.jsx
).
import React from 'react'; export default () => ( <div className="container-page"> <div className="row"> <div className="col-md-6 offset-md-3 col-xs-12"> <h1>Not Found</h1> <p>Sorry! We couldn't find the page you're looking for.</p> </div> </div> </div> );
Router, Again
After, we import the NotFound component to our AppRouter and provide it as the last option within the Switch tag, which renders if no other paths are matching (in app/static/js/AppRouter.jsx
).
#... import NotFound from './components/NotFound'; #... class AppRouter extends Component { #... render() { return ( <HashRouter> #... <Switch> #... <Route component={NotFound} /> </Switch> #...
User Not Found
We can also create a conditional render of the 'not found' component if a user searches for another user that doesn't exist (in app/static/js/components/Profile.jsx
).
https://gist.github.com/Porter97/28b5154cd08edfc0b09f59302c025adf
Sanity Check #2
Lastly, we check that the profile component is working as expected by launching the React app and the Flask app from the CLI. If you click on the gravatar image after signing in, you will see your new Profile component!
Final Thoughts
Congratulations! If you are coming from using vanilla HTML/JS/CSS with Flask, React-Redux authentication can be a big leap. However, we have now fully integrated our auth methods and routes along with a user profile page into our app. Now that we have these central components down, we can start diving into integrating Stream React components into our service as we create our first collections.
As always, thanks for reading and Happy Hacking!