├── .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 |
25 | {this.props.children}
26 |
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 |
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 |
--------------------------------------------------------------------------------