├── .gitignore ├── LICENSE ├── README.md ├── app ├── actions │ ├── constants.js │ └── index.js ├── auth │ ├── fakeRequest.js │ ├── fakeServer.js │ ├── index.js │ └── salt.js ├── components │ ├── Admin.js │ ├── Anonymous.js │ ├── App.js │ ├── Home.js │ ├── Login.js │ ├── NotFound.js │ ├── Register.js │ ├── User.js │ └── common │ │ ├── ErrorMessage.js │ │ ├── Form.js │ │ ├── LoadingButton.js │ │ ├── LoadingIndicator.js │ │ └── Nav.js ├── index.js ├── reducers │ └── index.js ├── sagas │ └── index.js └── styles │ ├── base │ ├── _base.css │ ├── _fonts.css │ ├── _helpers.css │ └── _typography.css │ ├── components │ ├── _buttons.css │ ├── _form-page.css │ ├── _form.css │ ├── _home.css │ ├── _loading-indicator.css │ └── _nav.css │ ├── layout │ ├── _footer.css │ └── _header.css │ ├── main.css │ ├── utils │ └── _variables.css │ └── vendor │ └── _normalize.css ├── index.html ├── package.json ├── server.js ├── test ├── actions.js ├── auth │ ├── login.js │ └── register.js ├── reducer.js └── sagas.js ├── webpack ├── dev.js ├── makeConfig.js └── prod.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 LaunchDarkly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saga Feature Flow 2 | 3 | > A login/register flow built with React & Redux Saga 4 | 5 | This application demonstrates what a React-based register/login workflow might look like with [LaunchDarkly](https://launchdarkly.com) feature flags 6 | 7 | It's based on Juan Soto's [saga-login-flow](https://github.com/sotojuan/saga-login-flow). 8 | 9 | ## Feature Flags 10 | 11 | Feature flags are served using LaunchDarkly. The homepage will display content depending on the value returned by our feature flag. Also, the navbar will change color based on the value of the header-bar-color feature flag. 12 | 13 | ## Authentication 14 | 15 | Authentication happens in `app/auth/index.js`, using `fakeRequest.js` and `fakeServer.js`. `fakeRequest` is a fake `XMLHttpRequest` wrapper. `fakeServer` responds to the fake HTTP requests and pretends to be a real server, storing the current users in local storage with the passwords encrypted using `bcrypt`. 16 | 17 | ## Thanks 18 | 19 | * [Juan Soto](https://juansoto.me/) for Saga Login flow 20 | * [Max Stoiber](https://twitter.com/mxstbr) for the Login Flow idea. 21 | * [Yassine Elouafi](https://github.com/yelouafi) for Redux Saga. Awesome! 22 | 23 | ## License 24 | 25 | MIT © [LaunchDarkly](https://launchdarkly.com) 26 | -------------------------------------------------------------------------------- /app/actions/constants.js: -------------------------------------------------------------------------------- 1 | /* 2 | * These are the variables that determine what our central data store (`../reducers/index.js`) 3 | * changes in our state. 4 | */ 5 | 6 | export const CHANGE_FORM = 'CHANGE_FORM' 7 | export const SET_AUTH = 'SET_AUTH' 8 | export const SENDING_REQUEST = 'SENDING_REQUEST' 9 | export const LOGIN_REQUEST = 'LOGIN_REQUEST' 10 | export const REGISTER_REQUEST = 'REGISTER_REQUEST' 11 | export const LOGOUT = 'LOGOUT' 12 | export const REQUEST_ERROR = 'REQUEST_ERROR' 13 | export const CLEAR_ERROR = 'CLEAR_ERROR' 14 | export const LD_INIT_REQUEST = 'LD_INIT_REQUEST' 15 | export const LD_INIT = 'LD_INIT' 16 | export const LD_FLAG_REQUEST = 'LD_FLAG_REQUEST' 17 | export const LD_REFRESH_HEADER = 'LD_REFRESH_HEADER' -------------------------------------------------------------------------------- /app/actions/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Actions describe changes of state in your application 3 | */ 4 | 5 | // We import constants to name our actions' type 6 | import { 7 | CHANGE_FORM, 8 | SET_AUTH, 9 | SENDING_REQUEST, 10 | LOGIN_REQUEST, 11 | REGISTER_REQUEST, 12 | LOGOUT, 13 | REQUEST_ERROR, 14 | CLEAR_ERROR, 15 | LD_INIT_REQUEST, 16 | LD_FLAG_REQUEST 17 | } from './constants' 18 | 19 | /** 20 | * Sets the form state 21 | * @param {object} newFormState The new state of the form 22 | * @param {string} newFormState.username The new text of the username input field of the form 23 | * @param {string} newFormState.password The new text of the password input field of the form 24 | */ 25 | export function changeForm (newFormState) { 26 | return {type: CHANGE_FORM, newFormState} 27 | } 28 | 29 | /** 30 | * Sets the authentication state of the application 31 | * @param {boolean} newAuthState True means a user is logged in, false means no user is logged in 32 | */ 33 | export function setAuthState (newAuthState) { 34 | return {type: SET_AUTH, newAuthState} 35 | } 36 | 37 | /** 38 | * Sets the `currentlySending` state, which displays a loading indicator during requests 39 | * @param {boolean} sending True means we're sending a request, false means we're not 40 | */ 41 | export function sendingRequest (sending) { 42 | return {type: SENDING_REQUEST, sending} 43 | } 44 | 45 | /** 46 | * Tells the app we want to log in a user 47 | * @param {object} data The data we're sending for log in 48 | * @param {string} data.username The username of the user to log in 49 | * @param {string} data.password The password of the user to log in 50 | */ 51 | export function loginRequest (data) { 52 | return {type: LOGIN_REQUEST, data} 53 | } 54 | 55 | /** 56 | * Tells the app we want to initialize the LaunchDarkly client 57 | */ 58 | export function ldInitRequest() { 59 | return {type: LD_INIT_REQUEST} 60 | } 61 | 62 | /** 63 | * Tells the app we want to log out a user 64 | */ 65 | export function logout () { 66 | return {type: LOGOUT} 67 | } 68 | 69 | /** 70 | * Tells the app we want to register a user 71 | * @param {object} data The data we're sending for registration 72 | * @param {string} data.username The username of the user to register 73 | * @param {string} data.password The password of the user to register 74 | */ 75 | export function registerRequest (data) { 76 | return {type: REGISTER_REQUEST, data} 77 | } 78 | 79 | /** 80 | * Sets the `error` state to the error received 81 | * @param {object} error The error we got when trying to make the request 82 | */ 83 | export function requestError (error) { 84 | return {type: REQUEST_ERROR, error} 85 | } 86 | 87 | /** 88 | * Sets the `error` state as empty 89 | */ 90 | export function clearError () { 91 | return {type: CLEAR_ERROR} 92 | } 93 | 94 | export function ldUpdateFlags () { 95 | return {type: LD_FLAG_REQUEST} 96 | } -------------------------------------------------------------------------------- /app/auth/fakeRequest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fake XMLHttpRequest wrapper 3 | */ 4 | 5 | import server from './fakeServer' 6 | 7 | server.init() 8 | 9 | let fakeRequest = { 10 | /** 11 | * Pretends to post to a remote server 12 | * @param {string} endpoint The endpoint of the server that should be contacted 13 | * @param {?object} data The data that should be transferred to the server 14 | */ 15 | post (endpoint, data) { 16 | switch (endpoint) { 17 | case '/login': 18 | return server.login(data.username, data.password) 19 | case '/register': 20 | return server.register(data.username, data.password) 21 | case '/logout': 22 | return server.logout() 23 | default: 24 | break 25 | } 26 | } 27 | } 28 | 29 | export default fakeRequest 30 | -------------------------------------------------------------------------------- /app/auth/fakeServer.js: -------------------------------------------------------------------------------- 1 | import {hashSync, genSaltSync, compareSync} from 'bcryptjs' 2 | import genSalt from './salt' 3 | 4 | let client 5 | let users 6 | let localStorage 7 | let salt = genSaltSync(10) 8 | 9 | // If we're testing, use a local storage polyfill 10 | if (global.process && process.env.NODE_ENV === 'test') { 11 | localStorage = require('localStorage') 12 | } else { 13 | // If not, use the browser one 14 | localStorage = global.window.localStorage 15 | } 16 | 17 | let server = { 18 | /** 19 | * Populates the users, similar to seeding a database in the real world 20 | */ 21 | init () { 22 | if (localStorage.users === undefined || !localStorage.encrypted) { 23 | // Set default user 24 | let admin = 'admin' 25 | let adminSalt = genSalt(admin) 26 | let adminPass = hashSync('password', adminSalt) 27 | 28 | users = { 29 | [admin]: { 30 | 'password': hashSync(adminPass, salt), 31 | 'groups': 'admin', 32 | 'key': Math.random().toString(36).substring(7) 33 | } 34 | } 35 | 36 | localStorage.users = JSON.stringify(users) 37 | localStorage.encrypted = true 38 | } else { 39 | users = JSON.parse(localStorage.users) 40 | } 41 | }, 42 | 43 | /** 44 | * Pretends to log a user in 45 | * 46 | * @param {string} username The username of the user 47 | * @param {string} password The password of the user 48 | */ 49 | login (username, password) { 50 | let userExists = this.doesUserExist(username) 51 | 52 | return new Promise((resolve, reject) => { 53 | // If the user exists and the password fits log the user in and resolve 54 | if (userExists && compareSync(password, users[username].password)) { 55 | resolve({ 56 | authenticated: true, 57 | token: users[username].key, 58 | groups: users[username].groups 59 | }) 60 | } else { 61 | // Set the appropiate error and reject 62 | let error 63 | 64 | if (userExists) { 65 | error = new Error('Wrong password') 66 | } else { 67 | error = new Error('User doesn\'t exist') 68 | } 69 | 70 | reject(error) 71 | } 72 | }) 73 | }, 74 | 75 | /** 76 | * Pretends to register a user 77 | * 78 | * @param {string} username The username of the user 79 | * @param {string} password The password of the user 80 | */ 81 | register (username, password) { 82 | return new Promise((resolve, reject) => { 83 | // If the username isn't used, hash the password with bcrypt to store it in localStorage 84 | if (!this.doesUserExist(username)) { 85 | users[username] = { 86 | 'password': hashSync(password, salt), 87 | 'groups': 'user', 88 | 'key': Math.random().toString(36).substring(7) 89 | } 90 | localStorage.users = JSON.stringify(users) 91 | 92 | // Resolve when done 93 | resolve({registered: true}) 94 | } else { 95 | // Reject with appropiate error 96 | reject(new Error('Username already in use')) 97 | } 98 | }) 99 | }, 100 | 101 | /** 102 | * Pretends to log a user out and resolves 103 | */ 104 | logout () { 105 | return new Promise(resolve => { 106 | localStorage.removeItem('token') 107 | localStorage.removeItem('groups') 108 | resolve(true) 109 | }) 110 | }, 111 | /** 112 | * Checks if a username exists in the db 113 | * @param {string} username The username that should be checked 114 | */ 115 | doesUserExist (username) { 116 | return !(users[username] === undefined) 117 | } 118 | } 119 | 120 | server.init() 121 | 122 | export default server 123 | -------------------------------------------------------------------------------- /app/auth/index.js: -------------------------------------------------------------------------------- 1 | import request from './fakeRequest' 2 | 3 | let localStorage 4 | 5 | // If we're testing, use a local storage polyfill 6 | if (global.process && process.env.NODE_ENV === 'test') { 7 | localStorage = require('localStorage') 8 | } else { 9 | // If not, use the browser one 10 | localStorage = global.window.localStorage 11 | } 12 | 13 | let auth = { 14 | /** 15 | * Logs a user in, returning a promise with `true` when done 16 | * @param {string} username The username of the user 17 | * @param {string} password The password of the user 18 | */ 19 | login (username, password) { 20 | if (auth.loggedIn()) return Promise.resolve(true) 21 | 22 | // Post a fake request 23 | return request.post('/login', {username, password}) 24 | .then(response => { 25 | // Save token to local storage 26 | localStorage.token = response.token 27 | localStorage.groups = response.groups 28 | return Promise.resolve(true) 29 | }) 30 | }, 31 | 32 | /** 33 | * Logs the current user out 34 | */ 35 | logout () { 36 | return request.post('/logout') 37 | }, 38 | 39 | /** 40 | * Checks if a user is logged in 41 | */ 42 | loggedIn () { 43 | return !!localStorage.token 44 | }, 45 | 46 | /** 47 | * Returns the user's key 48 | */ 49 | getToken () { 50 | return localStorage.token 51 | }, 52 | /** 53 | * Returns the user's group 54 | */ 55 | getGroup () { 56 | return localStorage.groups 57 | }, 58 | 59 | /** 60 | * Registers a user and then logs them in 61 | * @param {string} username The username of the user 62 | * @param {string} password The password of the user 63 | */ 64 | register (username, password) { 65 | // Post a fake request 66 | return request.post('/register', {username, password}) 67 | // Log user in after registering 68 | .then(() => auth.login(username, password)) 69 | }, 70 | onChange () {} 71 | } 72 | 73 | export default auth 74 | -------------------------------------------------------------------------------- /app/auth/salt.js: -------------------------------------------------------------------------------- 1 | import btoa from 'btoa' 2 | 3 | /** 4 | * Generate 16 bytes salt for bcrypt by seed. Should return the same salt for the same seed. 5 | * @param {string} seed The seed for salt 6 | */ 7 | export default function (seed) { 8 | let bytes = [] 9 | 10 | for (let i = 0, l = seed.length; i < l; i++) { 11 | bytes.push(seed.charCodeAt(i)) 12 | } 13 | 14 | // Salt must be 16 bytes 15 | while (bytes.length < 16) { 16 | bytes.push(0) 17 | } 18 | 19 | // Convert byte array to base64 string 20 | let salt = btoa(String.fromCharCode.apply(String, bytes.slice(0, 16))) 21 | 22 | // Adding header for bcrypt. Fake 10 rounds. 23 | return '$2a$10$' + salt 24 | }; 25 | -------------------------------------------------------------------------------- /app/components/Admin.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {connect} from 'react-redux' 3 | 4 | class Admin extends Component { 5 | render() { 6 | return ( 7 |
8 |
9 |

Welcome, Admin

10 |

You've logged in as an admin and the admin page is enabled.

11 |
12 |
13 | ) 14 | } 15 | } 16 | 17 | function select (state) { 18 | return { 19 | data: state 20 | } 21 | } 22 | 23 | export default connect(select)(Admin) -------------------------------------------------------------------------------- /app/components/Anonymous.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {connect} from 'react-redux' 3 | 4 | class Anonymous extends Component { 5 | render() { 6 | return ( 7 |
8 |
9 |

Welcome to Feature Flow!

10 |

This application demonstrates what a React-based register/login workflow might look like with feature flags.

11 | 12 |

It's based on Juan Soto's saga-login-flow.

13 | 14 |

Try logging in with username admin and password password, then try to register new users. They'll be saved in local storage so they'll persist across page reloads.

15 |
16 | 17 |
18 |

Feature Flags

19 |

Feature flags are served using LaunchDarkly. The homepage will display content depending on the value returned by our feature flag.

20 |
21 | 22 |
23 |

Authentication

24 |

Authentication happens in app/auth/index.js, using fakeRequest.js and fakeServer.js. fakeRequest is a fake XMLHttpRequest wrapper. fakeServer responds to the fake HTTP requests and pretends to be a real server, storing the current users in local storage with the passwords encrypted using bcrypt.

25 |
26 |
27 | ) 28 | } 29 | } 30 | 31 | function select (state) { 32 | return { 33 | data: state 34 | } 35 | } 36 | 37 | export default connect(select)(Anonymous) -------------------------------------------------------------------------------- /app/components/App.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {connect} from 'react-redux' 3 | import Nav from './common/Nav' 4 | import {ldInitRequest} from '../actions' 5 | 6 | class App extends Component { 7 | componentDidMount() { 8 | this.props.dispatch(ldInitRequest()) 9 | } 10 | 11 | render () { 12 | if(this.props.data.ld != undefined) { 13 | this.props.data.ld.on('change', function(settings) { 14 | console.log("Flags updated") 15 | }) 16 | } 17 | return ( 18 |
19 |
27 | ) 28 | } 29 | } 30 | 31 | 32 | 33 | App.propTypes = { 34 | data: React.PropTypes.object, 35 | history: React.PropTypes.object, 36 | location: React.PropTypes.object, 37 | children: React.PropTypes.object, 38 | dispatch: React.PropTypes.func 39 | } 40 | 41 | function select (state) { 42 | return { 43 | data: state 44 | } 45 | } 46 | 47 | export default connect(select)(App) 48 | -------------------------------------------------------------------------------- /app/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {connect} from 'react-redux' 3 | import auth from '../auth/index' 4 | import Admin from './Admin' 5 | import Anonymous from './Anonymous' 6 | import User from './User' 7 | 8 | class Home extends Component { 9 | render () { 10 | switch(this.props.data.flag) { 11 | case 2: 12 | return() 13 | case 1: 14 | return() 15 | case 0: 16 | default: 17 | return() 18 | } 19 | } 20 | } 21 | 22 | function select (state) { 23 | return { 24 | data: state 25 | } 26 | } 27 | 28 | export default connect(select)(Home) 29 | -------------------------------------------------------------------------------- /app/components/Login.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {connect} from 'react-redux' 3 | import Form from './common/Form' 4 | import {loginRequest} from '../actions' 5 | 6 | class Login extends Component { 7 | constructor (props) { 8 | super(props) 9 | this._login = this._login.bind(this) 10 | } 11 | 12 | render () { 13 | let {dispatch} = this.props 14 | let {formState, currentlySending, error} = this.props.data 15 | 16 | return ( 17 |
18 |
19 |
20 |

Login

21 |
22 |
23 |
24 |
25 | ) 26 | } 27 | 28 | _login (username, password) { 29 | this.props.dispatch(loginRequest({username, password})) 30 | } 31 | } 32 | 33 | Login.propTypes = { 34 | data: React.PropTypes.object, 35 | history: React.PropTypes.object, 36 | dispatch: React.PropTypes.func 37 | } 38 | 39 | // Which props do we want to inject, given the global state? 40 | function select (state) { 41 | return { 42 | data: state 43 | } 44 | } 45 | 46 | // Wrap the component to inject dispatch and state into it 47 | export default connect(select)(Login) 48 | -------------------------------------------------------------------------------- /app/components/NotFound.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {Link} from 'react-router' 3 | 4 | class NotFound extends Component { 5 | render () { 6 | return ( 7 |
8 |

Page not found.

9 | Home 10 |
11 | ) 12 | } 13 | } 14 | 15 | export default NotFound 16 | -------------------------------------------------------------------------------- /app/components/Register.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {connect} from 'react-redux' 3 | import Form from './common/Form' 4 | import {registerRequest} from '../actions' 5 | 6 | class Register extends Component { 7 | constructor (props) { 8 | super(props) 9 | 10 | this._register = this._register.bind(this) 11 | } 12 | 13 | render () { 14 | let {dispatch} = this.props 15 | let {formState, currentlySending, error} = this.props.data 16 | 17 | return ( 18 |
19 |
20 |
21 |

Register

22 |
23 | 24 |
25 |
26 | ) 27 | } 28 | 29 | _register (username, password) { 30 | this.props.dispatch(registerRequest({username, password})) 31 | } 32 | } 33 | 34 | Register.propTypes = { 35 | data: React.PropTypes.object, 36 | history: React.PropTypes.object, 37 | dispatch: React.PropTypes.func 38 | } 39 | 40 | function select (state) { 41 | return { 42 | data: state 43 | } 44 | } 45 | 46 | export default connect(select)(Register) 47 | -------------------------------------------------------------------------------- /app/components/User.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {connect} from 'react-redux' 3 | 4 | class User extends Component { 5 | render() { 6 | return ( 7 |
8 |
9 |

Welcome, User

10 |

The feature flag indicated that you've logged in as a user.

11 |
12 |
13 | ) 14 | } 15 | } 16 | 17 | function select (state) { 18 | return { 19 | data: state 20 | } 21 | } 22 | 23 | export default connect(select)(User) -------------------------------------------------------------------------------- /app/components/common/ErrorMessage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function ErrorMessage (props) { 4 | return ( 5 |
6 |

7 | {props.error} 8 |

9 |
10 | ) 11 | } 12 | 13 | ErrorMessage.propTypes = { 14 | error: React.PropTypes.string 15 | } 16 | 17 | export default ErrorMessage 18 | -------------------------------------------------------------------------------- /app/components/common/Form.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import ErrorMessage from './ErrorMessage' 3 | import LoadingButton from './LoadingButton' 4 | 5 | import {changeForm} from '../../actions' 6 | 7 | class Form extends Component { 8 | constructor (props) { 9 | super(props) 10 | 11 | this._onSubmit = this._onSubmit.bind(this) 12 | this._changeUsername = this._changeUsername.bind(this) 13 | this._changePassword = this._changePassword.bind(this) 14 | } 15 | render () { 16 | let {error} = this.props 17 | 18 | return ( 19 | 20 | {error ? : null} 21 |
22 | 32 | 35 |
36 |
37 | 44 | 47 |
48 |
49 | {this.props.currentlySending ? ( 50 | 51 | ) : ( 52 | 55 | )} 56 |
57 | 58 | ) 59 | } 60 | 61 | _changeUsername (event) { 62 | this._emitChange({...this.props.data, username: event.target.value}) 63 | } 64 | 65 | _changePassword (event) { 66 | this._emitChange({...this.props.data, password: event.target.value}) 67 | } 68 | 69 | _emitChange (newFormState) { 70 | this.props.dispatch(changeForm(newFormState)) 71 | } 72 | 73 | _onSubmit (event) { 74 | event.preventDefault() 75 | this.props.onSubmit(this.props.data.username, this.props.data.password) 76 | } 77 | } 78 | 79 | Form.propTypes = { 80 | dispatch: React.PropTypes.func, 81 | data: React.PropTypes.object, 82 | onSubmit: React.PropTypes.func, 83 | changeForm: React.PropTypes.func, 84 | btnText: React.PropTypes.string, 85 | error: React.PropTypes.string, 86 | currentlySending: React.PropTypes.bool 87 | } 88 | 89 | export default Form 90 | -------------------------------------------------------------------------------- /app/components/common/LoadingButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LoadingIndicator from './LoadingIndicator' 3 | 4 | function LoadingButton (props) { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | LoadingButton.propTypes = { 13 | className: React.PropTypes.string 14 | } 15 | 16 | export default LoadingButton 17 | -------------------------------------------------------------------------------- /app/components/common/LoadingIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function LoadingIndicator () { 4 | return ( 5 |
6 | Loading 7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ) 23 | } 24 | 25 | export default LoadingIndicator 26 | -------------------------------------------------------------------------------- /app/components/common/Nav.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import LoadingButton from './LoadingButton' 3 | import {Link} from 'react-router' 4 | 5 | import {logout, clearError, ldUpdateFlags} from '../../actions' 6 | 7 | class Nav extends Component { 8 | constructor (props) { 9 | super(props) 10 | this._logout = this._logout.bind(this) 11 | this._clearError = this._clearError.bind(this) 12 | this._getFlags = this._getFlags.bind(this) 13 | } 14 | 15 | render () { 16 | let navButtons = this.props.loggedIn ? ( 17 |
18 | Home 19 | {this.props.currentlySending ? ( 20 | 21 | ) : ( 22 | Logout 23 | )} 24 |
25 | ) : ( 26 |
27 | Home 28 | Register 29 | Login 30 |
31 | ) 32 | let color = this.props.headerColor 33 | return ( 34 |
35 |
36 | 37 |

Login Flow

38 | 39 | {navButtons} 40 |
41 |
42 | ) 43 | } 44 | 45 | _getFlags () { 46 | this.props.dispatch(ldUpdateFlags()) 47 | } 48 | 49 | _logout () { 50 | this.props.dispatch(logout()) 51 | } 52 | 53 | _clearError () { 54 | this.props.dispatch(clearError()) 55 | } 56 | } 57 | 58 | Nav.propTypes = { 59 | loggedIn: React.PropTypes.bool, 60 | currentlySending: React.PropTypes.bool, 61 | dispatch: React.PropTypes.func 62 | } 63 | 64 | export default Nav 65 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | 3 | import React, {Component} from 'react' 4 | import ReactDOM from 'react-dom' 5 | import {Router, Route, browserHistory} from 'react-router' 6 | import {createStore, applyMiddleware} from 'redux' 7 | import createSagaMiddleware from 'redux-saga' 8 | import {Provider} from 'react-redux' 9 | import createLogger from 'redux-logger' 10 | import reducer from './reducers' 11 | import rootSaga from './sagas' 12 | import {clearError} from './actions' 13 | 14 | import './styles/main.css' 15 | 16 | import App from './components/App' 17 | import Home from './components/Home' 18 | import Login from './components/Login' 19 | import Register from './components/Register' 20 | import NotFound from './components/NotFound' 21 | 22 | let logger = createLogger({ 23 | // Ignore `CHANGE_FORM` actions in the logger, since they fire after every keystroke 24 | predicate: (getState, action) => action.type !== 'CHANGE_FORM' 25 | }) 26 | 27 | let sagaMiddleware = createSagaMiddleware() 28 | 29 | // Creates the Redux store using our reducer and the logger and saga middlewares 30 | let store = createStore(reducer, applyMiddleware(logger, sagaMiddleware)) 31 | // We run the root saga automatically 32 | sagaMiddleware.run(rootSaga) 33 | 34 | /** 35 | * Checks authentication status on route change 36 | * @param {object} nextState The state we want to change into when we change routes 37 | * @param {function} replace Function provided by React Router to replace the location 38 | */ 39 | function checkAuth (nextState, replace) { 40 | let {loggedIn} = store.getState() 41 | 42 | store.dispatch(clearError()) 43 | 44 | // Check if the path isn't home. That way we can apply specific logic to 45 | // display/render the path we want to 46 | if (nextState.location.pathname !== '/') { 47 | if (loggedIn) { 48 | if (nextState.location.state && nextState.location.pathname) { 49 | replace(nextState.location.pathname) 50 | } else { 51 | replace('/') 52 | } 53 | } 54 | } else { 55 | // If the user is already logged in, forward them to the homepage 56 | if (!loggedIn) { 57 | if (nextState.location.state && nextState.location.pathname) { 58 | replace(nextState.location.pathname) 59 | } else { 60 | replace('/') 61 | } 62 | } 63 | } 64 | } 65 | 66 | // Mostly boilerplate, except for the routes. These are the pages you can go to, 67 | // which are all wrapped in the App component, which contains the navigation etc 68 | class LoginFlow extends Component { 69 | render () { 70 | return ( 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | ) 85 | } 86 | } 87 | 88 | ReactDOM.render(, document.getElementById('app')) 89 | -------------------------------------------------------------------------------- /app/reducers/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * The reducer takes care of state changes in our app through actions 3 | */ 4 | 5 | import { 6 | CHANGE_FORM, 7 | SET_AUTH, 8 | SENDING_REQUEST, 9 | REQUEST_ERROR, 10 | CLEAR_ERROR, 11 | LD_INIT, 12 | LD_REFRESH_HEADER 13 | } from '../actions/constants' 14 | import auth from '../auth' 15 | 16 | 17 | // The initial application state 18 | let initialState = { 19 | formState: { 20 | username: '', 21 | password: '' 22 | }, 23 | error: '', 24 | currentlySending: false 25 | } 26 | 27 | // Takes care of changing the application state 28 | function reducer (state = initialState, action) { 29 | switch (action.type) { 30 | case LD_REFRESH_HEADER: 31 | return {...state, flag: action.flag, headerColor: action.headerColor} 32 | case LD_INIT: 33 | return {...state, ld: action.ld, flag: action.flag, headerColor: action.headerColor} 34 | case CHANGE_FORM: 35 | return {...state, formState: action.newFormState} 36 | case SET_AUTH: 37 | return {...state, loggedIn: action.newAuthState} 38 | case SENDING_REQUEST: 39 | return {...state, currentlySending: action.sending, flag: action.flag, headerColor: action.headerColor} 40 | case REQUEST_ERROR: 41 | return {...state, error: action.error} 42 | case CLEAR_ERROR: 43 | return {...state, error: ''} 44 | default: 45 | return state 46 | } 47 | } 48 | 49 | export default reducer 50 | -------------------------------------------------------------------------------- /app/sagas/index.js: -------------------------------------------------------------------------------- 1 | // This file contains the sagas used for async actions in our app. It's divided into 2 | // "effects" that the sagas call (`authorize` and `logout`) and the actual sagas themselves, 3 | // which listen for actions. 4 | 5 | // Sagas help us gather all our side effects (network requests in this case) in one place 6 | 7 | import {hashSync} from 'bcryptjs' 8 | import genSalt from '../auth/salt' 9 | import {browserHistory} from 'react-router' 10 | import {takeEvery, take, call, put, fork, race} from 'redux-saga/effects' 11 | import auth from '../auth' 12 | import client from '../auth' 13 | import Promise from 'bluebird' 14 | import ldClient from 'ldclient-js' 15 | 16 | import { 17 | SENDING_REQUEST, 18 | LOGIN_REQUEST, 19 | REGISTER_REQUEST, 20 | SET_AUTH, 21 | LOGOUT, 22 | CHANGE_FORM, 23 | REQUEST_ERROR, 24 | LD_INIT_REQUEST, 25 | LD_INIT, 26 | LD_FLAG_REQUEST, 27 | LD_REFRESH_HEADER 28 | } from '../actions/constants' 29 | 30 | var ld 31 | 32 | export function * watchFlagUpdate () { 33 | while (true) { 34 | yield take(LD_FLAG_REQUEST) 35 | let flags = ld.allFlags() 36 | yield put({type:LD_REFRESH_HEADER, flag: flags['user-type'], headerColor: flags['header-bar-color']}) 37 | } 38 | } 39 | 40 | function idLD (user) { 41 | var ldPromise = Promise.promisify(ld.identify) 42 | return ldPromise(user, null).then(function () { 43 | return ld.allFlags() 44 | }) 45 | } 46 | 47 | function getLD () { 48 | var ldPromise = Promise.promisify(ld.on) 49 | return ldPromise('ready').then(function () { 50 | return ld.allFlags() 51 | }) 52 | } 53 | 54 | /** 55 | * Effect to handle LaunchDarkly client initialization 56 | */ 57 | export function * initLD () { 58 | let user = {key:Math.random().toString(36).substring(7), anonymous:true} 59 | ld = ldClient.initialize('5f41df0e46a5d909152d7ef6', user) 60 | let flags = yield call(getLD, user) 61 | yield put({type: LD_INIT, ld: ld, flag: flags['user-type'], headerColor: flags['header-bar-color']}) 62 | } 63 | 64 | /** 65 | * LaunchDarkly client initialization saga 66 | */ 67 | export function * watchInitLD () { 68 | yield takeEvery(LD_INIT_REQUEST, initLD) 69 | } 70 | 71 | /** 72 | * Effect to handle authorization 73 | * @param {string} username The username of the user 74 | * @param {string} password The password of the user 75 | * @param {object} options Options 76 | * @param {boolean} options.isRegistering Is this a register request? 77 | */ 78 | export function * authorize ({username, password, isRegistering}) { 79 | // We send an action that tells Redux we're sending a request 80 | yield put({type: SENDING_REQUEST, sending: true}) 81 | let flag 82 | // We then try to register or log in the user, depending on the request 83 | try { 84 | let salt = genSalt(username) 85 | let hash = hashSync(password, salt) 86 | let response 87 | // For either log in or registering, we call the proper function in the `auth` 88 | // module, which is asynchronous. Because we're using generators, we can work 89 | // as if it's synchronous because we pause execution until the call is done 90 | // with `yield`! 91 | if (isRegistering) { 92 | response = yield call(auth.register, username, hash) 93 | } else { 94 | response = yield call(auth.login, username, hash) 95 | } 96 | 97 | return response 98 | } catch (error) { 99 | // If we get an error we send Redux the appropiate action and return 100 | yield put({type: REQUEST_ERROR, error: error.message}) 101 | return false 102 | } finally { 103 | // When done, we tell Redux we're not in the middle of a request any more and 104 | // update the feature flag 105 | let user = {key: auth.getToken(), custom: {groups:auth.getGroup()}, anonymous:false} 106 | let flags = yield call(idLD, user) 107 | console.log(flags['header-bar-color']) 108 | yield put({type: SENDING_REQUEST, sending: false, flag: flags['user-type'], headerColor: flags['header-bar-color']}) 109 | } 110 | } 111 | 112 | /** 113 | * Effect to handle logging out 114 | */ 115 | export function * logout () { 116 | // We tell Redux we're in the middle of a request 117 | yield put({type: SENDING_REQUEST, sending: true}) 118 | 119 | // Similar to above, we try to log out by calling the `logout` function in the 120 | // `auth` module. If we get an error, we send an appropiate action. If we don't, 121 | // we return the response. 122 | try { 123 | let response = yield call(auth.logout) 124 | let user = {key:Math.random().toString(36).substring(7), anonymous:true} 125 | let flags = yield call(idLD, user) 126 | yield put({type: SENDING_REQUEST, sending: false, flag: flags['user-type'], headerColor: flags['header-bar-color']}) 127 | 128 | return response 129 | } catch (error) { 130 | yield put({type: REQUEST_ERROR, error: error.message}) 131 | } 132 | } 133 | 134 | /** 135 | * Log in saga 136 | */ 137 | export function * loginFlow () { 138 | // Because sagas are generators, doing `while (true)` doesn't block our program 139 | // Basically here we say "this saga is always listening for actions" 140 | while (true) { 141 | // And we're listening for `LOGIN_REQUEST` actions and destructuring its payload 142 | let request = yield take(LOGIN_REQUEST) 143 | let {username, password} = request.data 144 | 145 | // A `LOGOUT` action may happen while the `authorize` effect is going on, which may 146 | // lead to a race condition. This is unlikely, but just in case, we call `race` which 147 | // returns the "winner", i.e. the one that finished first 148 | let winner = yield race({ 149 | auth: call(authorize, {username, password, isRegistering: false}), 150 | logout: take(LOGOUT) 151 | }) 152 | 153 | // If `authorize` was the winner... 154 | if (winner.auth) { 155 | // ...we send Redux appropiate actions 156 | yield put({type: SET_AUTH, newAuthState: true}) // User is logged in (authorized) 157 | yield put({type: CHANGE_FORM, newFormState: {username: '', password: ''}}) // Clear form 158 | forwardTo('/') // Go to home page 159 | // If `logout` won... 160 | } else if (winner.logout) { 161 | // ...we send Redux appropiate action 162 | yield put({type: SET_AUTH, newAuthState: false}) // User is not logged in (not authorized) 163 | yield call(logout) // Call `logout` effect 164 | forwardTo('/') // Go to home page 165 | } 166 | } 167 | } 168 | 169 | /** 170 | * Log out saga 171 | * This is basically the same as the `if (winner.logout)` of above, just written 172 | * as a saga that is always listening to `LOGOUT` actions 173 | */ 174 | export function * logoutFlow () { 175 | while (true) { 176 | yield take(LOGOUT) 177 | yield put({type: SET_AUTH, newAuthState: false}) 178 | 179 | yield call(logout) 180 | forwardTo('/') 181 | } 182 | } 183 | 184 | /** 185 | * Register saga 186 | * Very similar to log in saga! 187 | */ 188 | export function * registerFlow () { 189 | while (true) { 190 | // We always listen to `REGISTER_REQUEST` actions 191 | let request = yield take(REGISTER_REQUEST) 192 | let {username, password} = request.data 193 | 194 | // We call the `authorize` task with the data, telling it that we are registering a user 195 | // This returns `true` if the registering was successful, `false` if not 196 | let wasSuccessful = yield call(authorize, {username, password, isRegistering: true}) 197 | 198 | // If we could register a user, we send the appropiate actions 199 | if (wasSuccessful) { 200 | yield put({type: SET_AUTH, newAuthState: true}) // User is logged in (authorized) after being registered 201 | yield put({type: CHANGE_FORM, newFormState: {username: '', password: ''}}) // Clear form 202 | forwardTo('/') // Go to home page 203 | } 204 | } 205 | } 206 | 207 | // The root saga is what we actually send to Redux's middleware. In here we fork 208 | // each saga so that they are all "active" and listening. 209 | // Sagas are fired once at the start of an app and can be thought of as processes running 210 | // in the background, watching actions dispatched to the store. 211 | export default function * root () { 212 | yield fork(watchInitLD) 213 | yield fork(watchFlagUpdate) 214 | yield fork(loginFlow) 215 | yield fork(logoutFlow) 216 | yield fork(registerFlow) 217 | } 218 | 219 | // Little helper function to abstract going to different pages 220 | function forwardTo (location) { 221 | browserHistory.push(location) 222 | } 223 | -------------------------------------------------------------------------------- /app/styles/base/_base.css: -------------------------------------------------------------------------------- 1 | /* This file contains very basic styles. */ 2 | 3 | /** 4 | * Set up a decent box model on the root element 5 | */ 6 | html { 7 | box-sizing: border-box; 8 | } 9 | 10 | /** 11 | * Make all elements from the DOM inherit from the parent box-sizing 12 | * Since `*` has a specificity of 0, it does not override the `html` value 13 | * making all elements inheriting from the root box-sizing value 14 | * See: https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ 15 | */ 16 | *, *::before, *::after { 17 | box-sizing: inherit; 18 | } 19 | 20 | html, 21 | body { 22 | margin: 0; 23 | padding: 0; 24 | width: 100%; 25 | height: 100%; 26 | } 27 | 28 | body { 29 | font-family: Helvetica Neue, Helvetica, Arial, sans-serif; 30 | padding-top: $nav-height; 31 | background-color: $background-color; 32 | } 33 | 34 | body.js-open-sans-loaded { 35 | font-family: 'Open Sans', Helvetica Neue, Helvetica, Arial, sans-serif; 36 | } 37 | -------------------------------------------------------------------------------- /app/styles/base/_fonts.css: -------------------------------------------------------------------------------- 1 | /* This file contains all @font-face declarations, if any. */ 2 | -------------------------------------------------------------------------------- /app/styles/base/_helpers.css: -------------------------------------------------------------------------------- 1 | /* This file contains CSS helper classes. */ 2 | 3 | /** 4 | * Clear inner floats 5 | */ 6 | .clearfix::after { 7 | clear: both; 8 | content: ''; 9 | display: table; 10 | } 11 | 12 | /** 13 | * Hide text while making it readable for screen readers 14 | * Needed in WebKit-based browsers because of an implementation bug 15 | * See: https://code.google.com/p/chromium/issues/detail?id=457146 16 | */ 17 | .hide-text { 18 | overflow: hidden; 19 | padding: 0; /* 1 */ 20 | text-indent: 101%; 21 | white-space: nowrap; 22 | } 23 | 24 | /** 25 | * Hide element while making it readable for screen readers 26 | * Shamelessly borrowed from HTML5Boilerplate: 27 | * https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css#L119-L133 28 | */ 29 | .visually-hidden { 30 | border: 0; 31 | clip: rect(0 0 0 0); 32 | height: 1px; 33 | margin: -1px; 34 | overflow: hidden; 35 | padding: 0; 36 | position: absolute; 37 | width: 1px; 38 | } 39 | -------------------------------------------------------------------------------- /app/styles/base/_typography.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Basic typography style for copy text 3 | */ 4 | body { 5 | color: $text-color; 6 | font-family: $text-font-stack; 7 | font-kerning: normal; 8 | font-variant-ligatures: common-ligatures, contextual; 9 | font-feature-settings: "kern", "liga", "clig", "calt"; 10 | } 11 | 12 | p { 13 | font-family: $serif-font-stack; 14 | line-height: 1.5em; 15 | } 16 | 17 | ul, 18 | ol { 19 | font-family: $serif-font-stack; 20 | padding-left: 1.75em; 21 | } 22 | 23 | a { 24 | text-decoration: none; 25 | color: $dark-brand-color; 26 | } 27 | 28 | a:hover { 29 | color: $brand-color; 30 | } 31 | 32 | code { 33 | font-size: 0.75em; 34 | background-color: $very-light-grey; 35 | padding: 0.125em 0.5em; 36 | border-radius: 1px; 37 | } 38 | -------------------------------------------------------------------------------- /app/styles/components/_buttons.css: -------------------------------------------------------------------------------- 1 | /* This file contains all styles related to the button component. */ 2 | 3 | .btn { 4 | padding: 0.5em 1.5em; 5 | color: '#6CC0E5'; 6 | border: 1px solid $brand-color; 7 | border-radius: 3px; 8 | text-decoration: none; 9 | user-select: none; 10 | display: inline-block; 11 | background-color: $background-color; 12 | } 13 | 14 | .btn:hover { 15 | color: white; 16 | background-color: $brand-color; 17 | } 18 | 19 | .btn + .btn { 20 | margin-left: 1em; 21 | } 22 | 23 | .btn--loading { 24 | color: white; 25 | background-color: $brand-color; 26 | } 27 | -------------------------------------------------------------------------------- /app/styles/components/_form-page.css: -------------------------------------------------------------------------------- 1 | .form-page__wrapper { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100%; 6 | width: 100%; 7 | } 8 | 9 | .form-page__form-wrapper { 10 | max-width: 325px; 11 | width: 100%; 12 | border: 1px solid $very-light-grey; 13 | border-radius: 3px; 14 | box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25); 15 | background-color: #fff; 16 | } 17 | 18 | .form-page__form-heading { 19 | text-align: center; 20 | font-size: 1em; 21 | user-select: none; 22 | } 23 | 24 | .form-page__form-header { 25 | padding: 1em; 26 | } 27 | -------------------------------------------------------------------------------- /app/styles/components/_form.css: -------------------------------------------------------------------------------- 1 | .form__field-wrapper { 2 | width: 100%; 3 | position: relative; 4 | padding-top: 1.75em; 5 | border-top: 1px solid $very-light-grey; 6 | border-bottom: 1px solid $very-light-grey; 7 | background-color: #fff; 8 | } 9 | 10 | .form__field-wrapper + .form__field-wrapper { 11 | border-top: none; 12 | } 13 | 14 | .form__field-input:focus ~ .form__field-label { 15 | color: $dark-grey; 16 | background-color: $very-light-grey; 17 | } 18 | 19 | .form__field-input:focus { 20 | background-color: $very-light-grey; 21 | color: $very-dark-grey; 22 | } 23 | 24 | .form__field-label { 25 | position: absolute; 26 | top: 0; 27 | left: 0; 28 | width: 100%; 29 | padding: 16px; 30 | padding-top: 20px; 31 | padding-bottom: 0; 32 | margin: 0; 33 | z-index: 1; 34 | font-size: .8em; 35 | color: $mid-grey; 36 | font-weight: 400; 37 | user-select: none; 38 | cursor: text; 39 | } 40 | 41 | .form__field-input { 42 | position: relative; 43 | padding: 1.625em 16px; 44 | width: 100%; 45 | color: $dark-grey; 46 | border: none; 47 | outline: 0; 48 | letter-spacing: 0.05em; 49 | } 50 | 51 | .form__submit-btn-wrapper { 52 | padding: 2em 1em; 53 | width: 100%; 54 | background-color: #fff; 55 | display: flex; 56 | justify-content: center; 57 | } 58 | 59 | .form__submit-btn { 60 | border: none; 61 | background-color: #fff; 62 | border: 1px solid $brand-color; 63 | padding: 0.5em 1em; 64 | border-radius: 3px; 65 | background-color: $brand-color; 66 | color: white; 67 | display: block; 68 | margin: 0 auto; 69 | position: relative; 70 | } 71 | 72 | .js-form__err-animation { 73 | animation: shake 150ms ease-in-out; 74 | } 75 | 76 | .form__error-wrapper { 77 | // display: none; 78 | justify-content: center; 79 | max-width: calc(100% - 2em); 80 | margin: 0 auto; 81 | margin-bottom: 1em; 82 | } 83 | 84 | .form__error { 85 | // display: none; 86 | background-color: $error-color; 87 | color: white; 88 | margin: 0; 89 | padding: 0.5em 1em; 90 | font-size: 0.8em; 91 | font-family: $text-font-stack; 92 | user-select: none; 93 | } 94 | 95 | .js-form__err .form__error-wrapper { 96 | display: flex; 97 | } 98 | 99 | .js-form__err--user-doesnt-exist .form__error--username-not-registered, 100 | .js-form__err--username-exists .form__error--username-taken, 101 | .js-form__err--password-wrong .form__error--wrong-password, 102 | .js-form__err--field-missing .form__error--field-missing { 103 | display: inline-block; 104 | } 105 | 106 | @keyframes shake { 107 | 0% { 108 | transform: translateX(0); 109 | } 110 | 25% { 111 | transform: translateX(10px); 112 | } 113 | 75% { 114 | transform: translateX(-10px); 115 | } 116 | 100% { 117 | transform: translateX(0); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /app/styles/components/_home.css: -------------------------------------------------------------------------------- 1 | $wrapper-padding: 16px; 2 | 3 | .wrapper { 4 | max-width: calc($max-width + $wrapper-padding * 2); 5 | margin: 0 auto; 6 | display: flex; 7 | height: 100%; 8 | padding: 0 $wrapper-padding; 9 | } 10 | 11 | #app { 12 | height: 100%; 13 | } 14 | 15 | .text-section { 16 | margin: 3em auto; 17 | } 18 | -------------------------------------------------------------------------------- /app/styles/components/_loading-indicator.css: -------------------------------------------------------------------------------- 1 | .sk-fading-circle { 2 | margin: 0 auto; 3 | width: 1em; 4 | height: 1em; 5 | position: relative; 6 | display: inline-block; 7 | margin-left: 0.5em; 8 | vertical-align: bottom; 9 | } 10 | 11 | .sk-fading-circle .sk-circle { 12 | width: 100%; 13 | height: 100%; 14 | position: absolute; 15 | left: 0; 16 | top: 0; 17 | } 18 | 19 | .sk-fading-circle .sk-circle:before { 20 | content: ''; 21 | display: block; 22 | margin: 0 auto; 23 | width: 15%; 24 | height: 15%; 25 | background-color: #FFF; 26 | border-radius: 100%; 27 | -webkit-animation: sk-circleFadeDelay 1.2s infinite ease-in-out both; 28 | animation: sk-circleFadeDelay 1.2s infinite ease-in-out both; 29 | } 30 | .sk-fading-circle .sk-circle2 { 31 | -webkit-transform: rotate(30deg); 32 | -ms-transform: rotate(30deg); 33 | transform: rotate(30deg); 34 | } 35 | .sk-fading-circle .sk-circle3 { 36 | -webkit-transform: rotate(60deg); 37 | -ms-transform: rotate(60deg); 38 | transform: rotate(60deg); 39 | } 40 | .sk-fading-circle .sk-circle4 { 41 | -webkit-transform: rotate(90deg); 42 | -ms-transform: rotate(90deg); 43 | transform: rotate(90deg); 44 | } 45 | .sk-fading-circle .sk-circle5 { 46 | -webkit-transform: rotate(120deg); 47 | -ms-transform: rotate(120deg); 48 | transform: rotate(120deg); 49 | } 50 | .sk-fading-circle .sk-circle6 { 51 | -webkit-transform: rotate(150deg); 52 | -ms-transform: rotate(150deg); 53 | transform: rotate(150deg); 54 | } 55 | .sk-fading-circle .sk-circle7 { 56 | -webkit-transform: rotate(180deg); 57 | -ms-transform: rotate(180deg); 58 | transform: rotate(180deg); 59 | } 60 | .sk-fading-circle .sk-circle8 { 61 | -webkit-transform: rotate(210deg); 62 | -ms-transform: rotate(210deg); 63 | transform: rotate(210deg); 64 | } 65 | .sk-fading-circle .sk-circle9 { 66 | -webkit-transform: rotate(240deg); 67 | -ms-transform: rotate(240deg); 68 | transform: rotate(240deg); 69 | } 70 | .sk-fading-circle .sk-circle10 { 71 | -webkit-transform: rotate(270deg); 72 | -ms-transform: rotate(270deg); 73 | transform: rotate(270deg); 74 | } 75 | .sk-fading-circle .sk-circle11 { 76 | -webkit-transform: rotate(300deg); 77 | -ms-transform: rotate(300deg); 78 | transform: rotate(300deg); 79 | } 80 | .sk-fading-circle .sk-circle12 { 81 | -webkit-transform: rotate(330deg); 82 | -ms-transform: rotate(330deg); 83 | transform: rotate(330deg); 84 | } 85 | .sk-fading-circle .sk-circle2:before { 86 | -webkit-animation-delay: -1.1s; 87 | animation-delay: -1.1s; 88 | } 89 | .sk-fading-circle .sk-circle3:before { 90 | -webkit-animation-delay: -1s; 91 | animation-delay: -1s; 92 | } 93 | .sk-fading-circle .sk-circle4:before { 94 | -webkit-animation-delay: -0.9s; 95 | animation-delay: -0.9s; 96 | } 97 | .sk-fading-circle .sk-circle5:before { 98 | -webkit-animation-delay: -0.8s; 99 | animation-delay: -0.8s; 100 | } 101 | .sk-fading-circle .sk-circle6:before { 102 | -webkit-animation-delay: -0.7s; 103 | animation-delay: -0.7s; 104 | } 105 | .sk-fading-circle .sk-circle7:before { 106 | -webkit-animation-delay: -0.6s; 107 | animation-delay: -0.6s; 108 | } 109 | .sk-fading-circle .sk-circle8:before { 110 | -webkit-animation-delay: -0.5s; 111 | animation-delay: -0.5s; 112 | } 113 | .sk-fading-circle .sk-circle9:before { 114 | -webkit-animation-delay: -0.4s; 115 | animation-delay: -0.4s; 116 | } 117 | .sk-fading-circle .sk-circle10:before { 118 | -webkit-animation-delay: -0.3s; 119 | animation-delay: -0.3s; 120 | } 121 | .sk-fading-circle .sk-circle11:before { 122 | -webkit-animation-delay: -0.2s; 123 | animation-delay: -0.2s; 124 | } 125 | .sk-fading-circle .sk-circle12:before { 126 | -webkit-animation-delay: -0.1s; 127 | animation-delay: -0.1s; 128 | } 129 | 130 | @-webkit-keyframes sk-circleFadeDelay { 131 | 0%, 39%, 100% { opacity: 0; } 132 | 40% { opacity: 1; } 133 | } 134 | 135 | @keyframes sk-circleFadeDelay { 136 | 0%, 39%, 100% { opacity: 0; } 137 | 40% { opacity: 1; } 138 | } 139 | -------------------------------------------------------------------------------- /app/styles/components/_nav.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | height: $nav-height; 7 | box-shadow: 0 0 2px rgba(0,0,0,.5); 8 | padding: 1em; 9 | display: flex; 10 | align-items: center; 11 | background-color: #fff; 12 | z-index: 1; 13 | } 14 | 15 | .nav__wrapper { 16 | max-width: $max-width; 17 | width: 100%; 18 | display: flex; 19 | align-items: center; 20 | justify-content: space-between; 21 | margin: 0 auto; 22 | } 23 | 24 | .nav__logo-wrapper { 25 | text-decoration: none; 26 | } 27 | 28 | .nav__logo { 29 | font-size: 1em; 30 | margin: 0; 31 | user-select: none; 32 | color: $text-color; 33 | text-decoration: none; 34 | } 35 | 36 | .btn--nav { 37 | font-size: 0.8em; 38 | text-transform: uppercase; 39 | } 40 | 41 | .btn--nav + .btn--nav { 42 | margin-left: 1em; 43 | } 44 | 45 | @media screen and (max-width: 400px) { 46 | .nav__wrapper { 47 | justify-content: none; 48 | } 49 | 50 | .nav__logo-wrapper { 51 | margin: 0 auto; 52 | } 53 | 54 | .btn--nav { 55 | display: none; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/styles/layout/_footer.css: -------------------------------------------------------------------------------- 1 | /* This file contains all styles related to the footer of the site/application. */ 2 | -------------------------------------------------------------------------------- /app/styles/layout/_header.css: -------------------------------------------------------------------------------- 1 | /* This file contains all styles related to the header of the site/application. */ 2 | -------------------------------------------------------------------------------- /app/styles/main.css: -------------------------------------------------------------------------------- 1 | /* Do not write any CSS in here, add it to the appropriate module or make a new one and import it here. */ 2 | 3 | @charset 'UTF-8'; 4 | 5 | /* 1. Vendors */ 6 | @import 'vendor/_normalize.css'; 7 | 8 | /* 2. Configuration and helpers */ 9 | @import 'utils/_variables.css'; 10 | @import 'base/_helpers.css'; 11 | 12 | /* 3. Base stuff */ 13 | @import 'base/_base.css'; 14 | @import 'base/_typography.css'; 15 | 16 | /* 4. Layout-related sections */ 17 | @import 'layout/_header.css'; 18 | @import 'layout/_footer.css'; 19 | 20 | /* 5. Components */ 21 | @import 'components/_buttons.css'; 22 | @import 'components/_home.css'; 23 | @import 'components/_form-page.css'; 24 | @import 'components/_form.css'; 25 | @import 'components/_nav.css'; 26 | @import 'components/_loading-indicator.css'; 27 | -------------------------------------------------------------------------------- /app/styles/utils/_variables.css: -------------------------------------------------------------------------------- 1 | /* This file contains all application-wide CSS variables. */ 2 | 3 | $text-font-stack: 'Helvetica Neue Light', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; 4 | $code-font-stack: 'Courier New', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Monaco', monospace; 5 | $serif-font-stack: Georgia, Times, 'Times New Roman', serif; 6 | 7 | $brand-color: #6CC0E5; 8 | $dark-brand-color: #41ADDD; 9 | $error-color: #FB4F4F; 10 | 11 | $background-color: #FAFAFA; 12 | $very-light-grey: #EDEDED; 13 | $light-grey: #CCC; 14 | $mid-grey: #999; 15 | $dark-grey: #666; 16 | $very-dark-grey: #333; 17 | $text-color: #222; 18 | 19 | $max-width: 768px; 20 | $nav-height: 3.5em; 21 | -------------------------------------------------------------------------------- /app/styles/vendor/_normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS and IE text size adjust after device orientation change, 6 | * without disabling user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability of focused elements when they are also in an 95 | * active/hover state. 96 | */ 97 | 98 | a:active, 99 | a:hover { 100 | outline: 0; 101 | } 102 | 103 | /* Text-level semantics 104 | ========================================================================== */ 105 | 106 | /** 107 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 108 | */ 109 | 110 | abbr[title] { 111 | border-bottom: 1px dotted; 112 | } 113 | 114 | /** 115 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bold; 121 | } 122 | 123 | /** 124 | * Address styling not present in Safari and Chrome. 125 | */ 126 | 127 | dfn { 128 | font-style: italic; 129 | } 130 | 131 | /** 132 | * Address variable `h1` font-size and margin within `section` and `article` 133 | * contexts in Firefox 4+, Safari, and Chrome. 134 | */ 135 | 136 | h1 { 137 | font-size: 2em; 138 | margin: 0.67em 0; 139 | } 140 | 141 | /** 142 | * Address styling not present in IE 8/9. 143 | */ 144 | 145 | mark { 146 | background: #ff0; 147 | color: #000; 148 | } 149 | 150 | /** 151 | * Address inconsistent and variable font size in all browsers. 152 | */ 153 | 154 | small { 155 | font-size: 80%; 156 | } 157 | 158 | /** 159 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 160 | */ 161 | 162 | sub, 163 | sup { 164 | font-size: 75%; 165 | line-height: 0; 166 | position: relative; 167 | vertical-align: baseline; 168 | } 169 | 170 | sup { 171 | top: -0.5em; 172 | } 173 | 174 | sub { 175 | bottom: -0.25em; 176 | } 177 | 178 | /* Embedded content 179 | ========================================================================== */ 180 | 181 | /** 182 | * Remove border when inside `a` element in IE 8/9/10. 183 | */ 184 | 185 | img { 186 | border: 0; 187 | } 188 | 189 | /** 190 | * Correct overflow not hidden in IE 9/10/11. 191 | */ 192 | 193 | svg:not(:root) { 194 | overflow: hidden; 195 | } 196 | 197 | /* Grouping content 198 | ========================================================================== */ 199 | 200 | /** 201 | * Address margin not present in IE 8/9 and Safari. 202 | */ 203 | 204 | figure { 205 | margin: 1em 40px; 206 | } 207 | 208 | /** 209 | * Address differences between Firefox and other browsers. 210 | */ 211 | 212 | hr { 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome. 354 | */ 355 | 356 | input[type="search"] { 357 | -webkit-appearance: textfield; /* 1 */ 358 | box-sizing: content-box; /* 2 */ 359 | } 360 | 361 | /** 362 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 363 | * Safari (but not Chrome) clips the cancel button when the search input has 364 | * padding (and `textfield` appearance). 365 | */ 366 | 367 | input[type="search"]::-webkit-search-cancel-button, 368 | input[type="search"]::-webkit-search-decoration { 369 | -webkit-appearance: none; 370 | } 371 | 372 | /** 373 | * Define consistent border, margin, and padding. 374 | */ 375 | 376 | fieldset { 377 | border: 1px solid #c0c0c0; 378 | margin: 0 2px; 379 | padding: 0.35em 0.625em 0.75em; 380 | } 381 | 382 | /** 383 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 384 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 385 | */ 386 | 387 | legend { 388 | border: 0; /* 1 */ 389 | padding: 0; /* 2 */ 390 | } 391 | 392 | /** 393 | * Remove default vertical scrollbar in IE 8/9/10/11. 394 | */ 395 | 396 | textarea { 397 | overflow: auto; 398 | } 399 | 400 | /** 401 | * Don't inherit the `font-weight` (applied by a rule above). 402 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 403 | */ 404 | 405 | optgroup { 406 | font-weight: bold; 407 | } 408 | 409 | /* Tables 410 | ========================================================================== */ 411 | 412 | /** 413 | * Remove most spacing between table cells. 414 | */ 415 | 416 | table { 417 | border-collapse: collapse; 418 | border-spacing: 0; 419 | } 420 | 421 | td, 422 | th { 423 | padding: 0; 424 | } 425 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Feature Flow 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saga-feature-flow", 3 | "version": "0.0.0", 4 | "description": "A login/register flow built with React & Redux Saga", 5 | "scripts": { 6 | "start": "node server.js", 7 | "build": "webpack --config webpack/prod.js --progress --colors -p", 8 | "test": "standard | snazzy --verbose && NODE_ENV=test ava --verbose" 9 | }, 10 | "author": "LaunchDarkly ", 11 | "repository": "launchdarkly/saga-feature-flow", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "autoprefixer": "^6.6.1", 15 | "ava": "0.17.0", 16 | "babel-core": "6.21.0", 17 | "babel-loader": "^6.2.10", 18 | "babel-plugin-transform-object-rest-spread": "6.20.2", 19 | "babel-plugin-transform-runtime": "6.15.0", 20 | "babel-preset-es2015": "6.18.0", 21 | "babel-preset-react": "6.16.0", 22 | "babel-register": "6.18.0", 23 | "babel-runtime": "6.20.0", 24 | "css-loader": "0.26.1", 25 | "html-webpack-plugin": "^2.26.0", 26 | "localStorage": "1.0.3", 27 | "postcss-focus": "1.0.0", 28 | "postcss-import": "9.0.0", 29 | "postcss-loader": "1.2.1", 30 | "postcss-reporter": "3.0.0", 31 | "postcss-simple-vars": "3.0.0", 32 | "redux-ava": "2.2.0", 33 | "snazzy": "5.0.0", 34 | "standard": "8.6.0", 35 | "style-loader": "0.13.1", 36 | "surge": "0.18.0", 37 | "webpack": "1.14.0", 38 | "webpack-dev-server": "1.16.2" 39 | }, 40 | "dependencies": { 41 | "babel-polyfill": "6.20.0", 42 | "bcryptjs": "2.4.0", 43 | "btoa": "1.1.2", 44 | "ldclient-js": "^1.1.6", 45 | "react": "15.4.1", 46 | "react-dom": "15.4.1", 47 | "react-redux": "4.4.6", 48 | "react-router": "2.5.1", 49 | "redux": "3.6.0", 50 | "redux-logger": "2.7.4", 51 | "redux-saga": "0.14.2", 52 | "webpack": "^1.14.0" 53 | }, 54 | "babel": { 55 | "presets": [ 56 | "es2015", 57 | "react" 58 | ], 59 | "plugins": [ 60 | "transform-runtime", 61 | "transform-object-rest-spread" 62 | ] 63 | }, 64 | "ava": { 65 | "babel": "inherit", 66 | "require": [ 67 | "babel-register" 68 | ] 69 | }, 70 | "resolutions": { 71 | "ua-parser-js": "^0.7.30" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var WebpackDevServer = require('webpack-dev-server') 3 | var config = require('./webpack/dev') 4 | 5 | new WebpackDevServer(webpack(config), { 6 | publicPath: config.output.publicPath, 7 | hot: true, 8 | inline: false, 9 | historyApiFallback: true, 10 | quiet: true 11 | }).listen(3000, 'localhost', function (error, result) { 12 | if (error) { 13 | console.log(error) 14 | } 15 | 16 | console.log('Listening at http://localhost:3000!') 17 | }) 18 | -------------------------------------------------------------------------------- /test/actions.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {actionTest} from 'redux-ava' 3 | import { 4 | changeForm, 5 | setAuthState, 6 | sendingRequest, 7 | loginRequest, 8 | registerRequest, 9 | logout, 10 | requestError, 11 | clearError 12 | } from '../app/actions' 13 | 14 | let formState = { 15 | username: 'admin', 16 | password: 'password' 17 | } 18 | 19 | let error = 'Wrong password' 20 | 21 | test('changeForm action', 22 | actionTest(changeForm, formState, {type: 'CHANGE_FORM', newFormState: formState})) 23 | 24 | test('setAuthState action', 25 | actionTest(setAuthState, true, {type: 'SET_AUTH', newAuthState: true})) 26 | 27 | test('sendingRequest action', 28 | actionTest(sendingRequest, true, {type: 'SENDING_REQUEST', sending: true})) 29 | 30 | test('loginRequest action', 31 | actionTest(loginRequest, formState, {type: 'LOGIN_REQUEST', data: formState})) 32 | 33 | test('registerRequest action', 34 | actionTest(registerRequest, formState, {type: 'REGISTER_REQUEST', data: formState})) 35 | 36 | test('logout action', 37 | actionTest(logout, formState, {type: 'LOGOUT'})) 38 | 39 | test('requestError action', 40 | actionTest(requestError, error, {type: 'REQUEST_ERROR', error})) 41 | 42 | test('clearError action', 43 | actionTest(clearError, error, {type: 'CLEAR_ERROR'})) 44 | -------------------------------------------------------------------------------- /test/auth/login.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import auth from '../../app/auth' 3 | 4 | import {hashSync} from 'bcryptjs' 5 | import genSalt from '../../app/auth/salt' 6 | 7 | test('returns true on correct login', t => { 8 | let salt = genSalt('admin') 9 | let hash = hashSync('password', salt) 10 | 11 | auth.login('admin', hash) 12 | .then(response => { 13 | t.true(response) 14 | }) 15 | }) 16 | 17 | test('returns error on wrong password', t => { 18 | t.throws(auth.login('admin', 'wrong'), 'Wrong password') 19 | }) 20 | 21 | test('returns error on inexistent user', t => { 22 | t.throws(auth.login('banana', 'wrong'), 'User doesn\'t exist') 23 | }) 24 | 25 | test('stays logged in until log out', t => { 26 | let salt = genSalt('admin') 27 | let hash = hashSync('password', salt) 28 | 29 | auth.login('admin', hash) 30 | .then(() => { 31 | t.true(auth.loggedIn()) 32 | auth.logout(() => { 33 | t.false(auth.loggedIn()) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/auth/register.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import auth from '../../app/auth' 3 | 4 | test('registers when given good data', t => { 5 | auth.register('jennifer', 'password') 6 | .then(response => { 7 | t.true(response) 8 | t.true(auth.loggedIn()) 9 | }) 10 | }) 11 | 12 | test('returns error when given existing user', t => { 13 | t.throws(auth.register('admin', 'password', 'Username already in use')) 14 | }) 15 | -------------------------------------------------------------------------------- /test/reducer.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {reducerTest} from 'redux-ava' 3 | import { 4 | changeForm, 5 | setAuthState, 6 | sendingRequest, 7 | requestError, 8 | clearError 9 | } from '../app/actions' 10 | import app from '../app/reducers' 11 | 12 | let stateBefore = { 13 | formState: { 14 | username: '', 15 | password: '' 16 | }, 17 | error: 'Wrong password', 18 | currentlySending: false, 19 | loggedIn: false 20 | } 21 | 22 | test('reducer handles CHANGE_FORM action', reducerTest( 23 | app, 24 | stateBefore, 25 | changeForm({username: 'admin', password: 'password'}), 26 | {...stateBefore, formState: {username: 'admin', password: 'password'}} 27 | )) 28 | 29 | test('reducer handles SET_AUTH action', reducerTest( 30 | app, 31 | stateBefore, 32 | setAuthState(true), 33 | {...stateBefore, loggedIn: true} 34 | )) 35 | 36 | test('reducer handles SENDING_REQUEST action', reducerTest( 37 | app, 38 | stateBefore, 39 | sendingRequest(true), 40 | {...stateBefore, currentlySending: true} 41 | )) 42 | 43 | test('reducer handles REQUEST_ERROR action', reducerTest( 44 | app, 45 | stateBefore, 46 | requestError('Username already in use'), 47 | {...stateBefore, error: 'Username already in use'} 48 | )) 49 | 50 | test('reducer handles CLEAR_ERROR action', reducerTest( 51 | app, 52 | stateBefore, 53 | clearError(), 54 | {...stateBefore, error: ''} 55 | )) 56 | -------------------------------------------------------------------------------- /test/sagas.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {take, put, call, race} from 'redux-saga/effects' 3 | import * as constants from '../app/actions/constants' 4 | import * as actions from '../app/actions' 5 | import {logoutFlow, registerFlow, loginFlow, authorize, logout} from '../app/sagas' 6 | 7 | let user = {username: 'admin', password: 'password'} 8 | let data = {data: user} 9 | let blankForm = {username: '', password: ''} 10 | let raceObject = { 11 | auth: call(authorize, {...user, isRegistering: false}), 12 | logout: take(constants.LOGOUT) 13 | } 14 | 15 | test('loginFlow saga with success', t => { 16 | let gen = loginFlow() 17 | let loginRace = race(raceObject) 18 | let authWinner = {auth: true} 19 | 20 | t.deepEqual( 21 | gen.next().value, 22 | take(constants.LOGIN_REQUEST) 23 | ) 24 | 25 | t.deepEqual( 26 | gen.next(data).value, 27 | loginRace 28 | ) 29 | 30 | t.deepEqual( 31 | gen.next(authWinner).value, 32 | put(actions.setAuthState(true)) 33 | ) 34 | 35 | t.deepEqual( 36 | gen.next().value, 37 | put(actions.changeForm(blankForm)) 38 | ) 39 | }) 40 | 41 | test('loginFlow saga with logout as race winner', t => { 42 | let gen = loginFlow() 43 | let loginRace = race(raceObject) 44 | let logOutWinner = {logout: true} 45 | 46 | t.deepEqual( 47 | gen.next().value, 48 | take(constants.LOGIN_REQUEST) 49 | ) 50 | 51 | t.deepEqual( 52 | gen.next(data).value, 53 | loginRace 54 | ) 55 | 56 | t.deepEqual( 57 | gen.next(logOutWinner).value, 58 | put(actions.setAuthState(false)) 59 | ) 60 | 61 | t.deepEqual( 62 | gen.next().value, 63 | call(logout) 64 | ) 65 | }) 66 | 67 | test('logoutFlow saga', t => { 68 | let gen = logoutFlow() 69 | 70 | t.deepEqual( 71 | gen.next().value, 72 | take(constants.LOGOUT) 73 | ) 74 | 75 | t.deepEqual( 76 | gen.next().value, 77 | put(actions.setAuthState(false)) 78 | ) 79 | 80 | t.deepEqual( 81 | gen.next().value, 82 | call(logout) 83 | ) 84 | }) 85 | 86 | test('registerFlow saga with success', t => { 87 | let gen = registerFlow() 88 | 89 | t.deepEqual( 90 | gen.next().value, 91 | take(constants.REGISTER_REQUEST) 92 | ) 93 | 94 | t.deepEqual( 95 | gen.next(data).value, 96 | call(authorize, {...user, isRegistering: true}) 97 | ) 98 | 99 | t.deepEqual( 100 | gen.next(true).value, 101 | put(actions.setAuthState(true)) 102 | ) 103 | 104 | t.deepEqual( 105 | gen.next(true).value, 106 | put(actions.changeForm(blankForm)) 107 | ) 108 | }) 109 | -------------------------------------------------------------------------------- /webpack/dev.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let makeWebpackConfig = require('./makeConfig') 4 | 5 | module.exports = makeWebpackConfig({prod: false}) 6 | -------------------------------------------------------------------------------- /webpack/makeConfig.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let path = require('path') 4 | let webpack = require('webpack') 5 | let HtmlWebpackPlugin = require('html-webpack-plugin') 6 | 7 | function makeWebpackConfig (options) { 8 | let entry, plugins, devtool 9 | 10 | if (options.prod) { 11 | entry = [ 12 | path.resolve(__dirname, '../app/index.js') 13 | ] 14 | 15 | plugins = [ 16 | new webpack.optimize.UglifyJsPlugin({ 17 | compress: { 18 | warnings: false 19 | } 20 | }), 21 | new HtmlWebpackPlugin({ 22 | template: 'index.html', 23 | minify: { 24 | removeComments: true, 25 | collapseWhitespace: true, 26 | removeRedundantAttributes: true, 27 | useShortDoctype: true, 28 | removeEmptyAttributes: true, 29 | removeStyleLinkTypeAttributes: true, 30 | keepClosingSlash: true, 31 | minifyJS: true, 32 | minifyCSS: true, 33 | minifyURLs: true 34 | } 35 | }), 36 | new webpack.DefinePlugin({ 37 | 'process.env': { 38 | NODE_ENV: JSON.stringify('production') 39 | } 40 | }) 41 | ] 42 | } else { 43 | devtool = 'cheap-eval-source-map' 44 | 45 | entry = [ 46 | 'webpack-dev-server/client?http://localhost:3000', 47 | 'webpack/hot/only-dev-server', 48 | path.resolve(__dirname, '../app/index.js') 49 | ] 50 | 51 | plugins = [ 52 | new webpack.HotModuleReplacementPlugin(), 53 | new HtmlWebpackPlugin({ 54 | template: 'index.html' 55 | }) 56 | ] 57 | } 58 | 59 | return { 60 | devtool: devtool, 61 | entry: entry, 62 | output: { // Compile into `js/build.js` 63 | path: path.resolve(__dirname, '../', 'build'), 64 | filename: 'js/bundle.js' 65 | }, 66 | module: { 67 | loaders: [ 68 | { 69 | test: /\.js$/, // Transform all .js files required somewhere within an entry point... 70 | loader: 'babel', // ...with the specified loaders... 71 | exclude: path.join(__dirname, '../', '/node_modules/') // ...except for the node_modules folder. 72 | }, { 73 | test: /\.css$/, // Transform all .css files required somewhere within an entry point... 74 | loaders: ['style-loader', 'css-loader', 'postcss-loader'] // ...with PostCSS 75 | } 76 | ] 77 | }, 78 | plugins: plugins, 79 | postcss: function () { 80 | return [ 81 | require('postcss-import')({ 82 | onImport: function (files) { 83 | files.forEach(this.addDependency) 84 | }.bind(this) 85 | }), 86 | require('postcss-simple-vars')(), 87 | require('postcss-focus')(), 88 | require('autoprefixer')({ 89 | browsers: ['last 2 versions', 'IE > 8'] 90 | }), 91 | require('postcss-reporter')({ 92 | clearMessages: true 93 | }) 94 | ] 95 | }, 96 | target: 'web', 97 | stats: false, 98 | progress: true 99 | } 100 | } 101 | 102 | module.exports = makeWebpackConfig 103 | -------------------------------------------------------------------------------- /webpack/prod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let makeWebpackConfig = require('./makeConfig') 4 | 5 | module.exports = makeWebpackConfig({prod: true}) 6 | --------------------------------------------------------------------------------