Skip to content
This repository has been archived by the owner on Aug 5, 2022. It is now read-only.

Commit

Permalink
Week6/async errors (#14)
Browse files Browse the repository at this point in the history
* Install redux thunk

* Rewrite login action to thunk

* Add WIP on refresh token api client integration

* Add WIP on refreshing token

* Change config

* Finish api client

* Add sample error boundary

* Add sample error handles

* Rewrite logout to thunks

* Add logout functionality

* Fix Error Boundary

* Move global styles, change logout logic

* Update yarn.lock

* Add README

* Move tokens logic into login action

* SignUp logic

* Add video link
  • Loading branch information
dannytce committed Apr 24, 2019
1 parent 1f75a54 commit af41308
Show file tree
Hide file tree
Showing 21 changed files with 354 additions and 133 deletions.
15 changes: 15 additions & 0 deletions lectures/07-async-error-handling/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Authentication & Routing in depth

- [Presentation](https://docs.google.com/presentation/d/1JCvCloP-MmZAFA6oY0tCUs6jVs_pIzUavwJMl5UYtPY/edit)
- [Video](https://www.youtube.com/watch?v=V5URcw_KYFM)

## Homework

- move logic to chosen async solution _(default: redux-thunks, advanced: redux-sagas, redux-observables)_
- handle refresh tokens
- notify user about errors `server/network` and `success` _(product added to cart/deleted from cart/logged in)_

## Aditional info

- To mock server responses `401/500` you can use https://www.charlesproxy.com/
- If you found homework too easy, please refer to `Up for the challenge?` slide for [problem inspiration](https://docs.google.com/presentation/d/1JCvCloP-MmZAFA6oY0tCUs6jVs_pIzUavwJMl5UYtPY/edit#slide=id.g56a65efc51_0_57)
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
"react-router": "^5.0.0",
"react-router-dom": "^5.0.0",
"react-scripts": "2.1.8",
"react-toastify": "^5.1.0",
"redux": "^4.0.1",
"redux-thunk": "^2.3.0",
"sanitize.css": "^8.0.0",
"styled-components": "^4.2.0",
"styled-system": "^4.0.8",
Expand Down
35 changes: 21 additions & 14 deletions src/App.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import React from 'react'
import { Switch, Route, Redirect } from 'react-router-dom'
import { Provider } from 'react-redux'
import { ToastContainer, toast } from 'react-toastify'

import GlobalStyles from './globalStyles'
import { ProductList } from './pages/ProductList'
import { ProductDetail } from './pages/ProductDetail'
import { Cart } from './pages/Cart'
import { SignUp } from './pages/SignUp'
import { LogIn } from './pages/LogIn'
import { Logout } from './pages/Logout'
import { Account } from './pages/Account'
import { NotFound } from './pages/NotFound'
import { PrivateRoute } from './components/PrivateRoute'
import { ErrorBoundary } from './components/ErrorBoundary'
import { getCustomer } from './utils/customer'
import { configureStore } from './store'
import * as routes from './routes'
Expand All @@ -23,20 +26,24 @@ const App = () => (
<Provider store={store}>
<React.Fragment>
<GlobalStyles />
<Switch>
<Route
path={routes.HOMEPAGE}
exact
render={() => <Redirect to={routes.PRODUCT_LIST} />}
/>
<Route path={routes.PRODUCT_LIST} exact component={ProductList} />
<Route path={routes.PRODUCT_DETAIL} component={ProductDetail} />
<Route path={routes.CART} component={Cart} />
<Route path={routes.SIGN_UP} component={SignUp} />
<Route path={routes.LOGIN} component={LogIn} />
<PrivateRoute path={routes.ACCOUNT} component={Account} />
<Route component={NotFound} />
</Switch>
<ToastContainer position={toast.POSITION.BOTTOM_RIGHT} />
<ErrorBoundary>
<Switch>
<Route
path={routes.HOMEPAGE}
exact
render={() => <Redirect to={routes.PRODUCT_LIST} />}
/>
<Route path={routes.PRODUCT_LIST} exact component={ProductList} />
<Route path={routes.PRODUCT_DETAIL} component={ProductDetail} />
<Route path={routes.CART} component={Cart} />
<Route path={routes.SIGN_UP} component={SignUp} />
<Route path={routes.LOGIN} component={LogIn} />
<Route path={routes.LOGOUT} component={Logout} />
<PrivateRoute path={routes.ACCOUNT} component={Account} />
<Route component={NotFound} />
</Switch>
</ErrorBoundary>
</React.Fragment>
</Provider>
)
Expand Down
54 changes: 45 additions & 9 deletions src/api/api-client.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/* eslint-disable no-constant-condition */
/* eslint-disable no-await-in-loop */

import config from '../config'
import { LOGOUT } from '../routes'
import { getGuestToken } from './get-guest-token'
import { refreshCustomerToken } from './customers/refresh-customer-token'
import { getToken } from '../utils/token'
import { getRefreshToken } from '../utils/refresh-token'

export const api = async (url, options) => {
let token = getToken()

if (!token) {
token = await getGuestToken()
}

const response = await fetch(`${config.apiUrl}${url}`, {
const makeRequest = (url, options, token) =>
fetch(`${config.apiUrl}${url}`, {
method: 'GET',
headers: {
'Content-Type': 'application/vnd.api+json',
Expand All @@ -18,5 +18,41 @@ export const api = async (url, options) => {
...options,
})

return response.json()
export const api = async (url, options) => {
// Grab the token from the store or from the API
let token = getToken() || (await getGuestToken())

try {
// Do the request
let response = await makeRequest(url, options, token)

// 401 unauthorized means we have expired token
if (response && response.status === 401) {
const refreshToken = getRefreshToken()

// If we have a refresh token this means we have logged in user
// and we need to refresh access token
if (refreshToken) {
token = await refreshCustomerToken()
} else {
// If no refresh token is present just get new guest token
token = await getGuestToken()
}

// Repeat the request with the new token
response = await makeRequest(url, options, token)
}

// Here is a place to handle special cases
// CASE: second 401 we need to logout
if (response && response.status === 401) {
window.location.assign(LOGOUT)
}

// If everything went fine just return the result
return response.json()
} catch (e) {
// Place to handle global api errors
throw e
}
}
15 changes: 1 addition & 14 deletions src/api/customers/create-customer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { api } from '../api-client'
import { getCustomerToken } from './get-customer-token'

export const createCustomer = async ({ email, password, firstName }) => {
const requestBody = {
Expand All @@ -20,19 +19,7 @@ export const createCustomer = async ({ email, password, firstName }) => {
body: JSON.stringify(requestBody),
})

if (!response.errors) {
const {
data: { attributes },
} = response

const { ownerId } = await getCustomerToken({ username: email, password })

return {
ownerId,
username: attributes.email,
firstName: attributes.metadata.firstName,
}
} else {
if (response.errors) {
const firstError = response.errors[0]
if (firstError.status === '422') {
throw new Error('Email is already registered')
Expand Down
9 changes: 4 additions & 5 deletions src/api/customers/get-customer-token.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import config from '../../config'
import { setToken } from '../../utils/token'
import { AsyncValidationError } from '../../utils/errors'

export const getCustomerToken = async ({ username, password }) => {
const response = await fetch(`${config.apiUrl}/oauth/token`, {
Expand All @@ -18,13 +18,12 @@ export const getCustomerToken = async ({ username, password }) => {

switch (response.status) {
case 200: {
const { owner_id, access_token } = await response.json()
setToken(access_token)
const { owner_id, access_token, refresh_token } = await response.json()

return { ownerId: owner_id, access_token }
return { ownerId: owner_id, access_token, refresh_token }
}
case 401:
throw new Error('Email or password are incorrect')
throw new AsyncValidationError('Email or password are incorrect')
default:
throw new Error('Unexpected error')
}
Expand Down
35 changes: 35 additions & 0 deletions src/api/customers/refresh-customer-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { setRefreshToken, getRefreshToken } from '../../utils/refresh-token'
import { setToken, getToken } from '../../utils/token'

import config from '../../config'

export const refreshCustomerToken = async () => {
const refreshToken = getRefreshToken()
const token = getToken()

const response = await fetch(`${config.apiUrl}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
})

switch (response.status) {
case 200: {
const { refresh_token, access_token } = await response.json()
setToken(access_token)
setRefreshToken(refresh_token)

return access_token
}
default:
throw new Error('Cannot refresh customer token')
}
}
30 changes: 30 additions & 0 deletions src/components/ErrorBoundary/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react'
import { toast } from 'react-toastify'

export class ErrorBoundary extends React.Component {
state = {
error: false,
}

static getDerivedStateFromError() {
return {
error: true,
}
}

componentDidCatch(error, info) {
toast.error(`Error: ${error.message}`)
console.error('Error boundary error', error, info)
}

render() {
return this.state.error ? (
<h2>
We are sorry! There was an error which we were not able to recover from.
Please refresh the page
</h2>
) : (
this.props.children
)
}
}
74 changes: 25 additions & 49 deletions src/components/Layout/index.js
Original file line number Diff line number Diff line change
@@ -1,61 +1,37 @@
import React, { Fragment } from 'react'
import { withRouter } from 'react-router-dom'
import { connect } from 'react-redux'

import * as customerActions from '../../store/customer/actions'
import * as routes from '../../routes'

import { removeToken } from '../../utils/token'
import { removeCustomer } from '../../utils/customer'
import { Wrapper, Header, HeaderSection, HeaderLink } from './styled'

const Layout = ({ logout, isAuthenticated, history, children }) => {
const handleLogout = () => {
logout()
removeToken()
removeCustomer()
history.push(routes.HOMEPAGE)
}

return (
<Fragment>
<Header>
<HeaderSection>
<HeaderLink to={routes.PRODUCT_LIST}>All Products</HeaderLink>
</HeaderSection>
<HeaderSection>
<HeaderLink to={routes.CART}>My Cart</HeaderLink>|
{isAuthenticated ? (
<>
<HeaderLink to={routes.ACCOUNT}>My Account</HeaderLink>|
<HeaderLink as="button" onClick={handleLogout}>
Logout
</HeaderLink>
</>
) : (
<>
<HeaderLink to={routes.LOGIN}>Log In</HeaderLink> |
<HeaderLink to={routes.SIGN_UP}>Sign Up</HeaderLink>
</>
)}
</HeaderSection>
</Header>
<Wrapper>{children}</Wrapper>
</Fragment>
)
}
const Layout = ({ isAuthenticated, children }) => (
<Fragment>
<Header>
<HeaderSection>
<HeaderLink to={routes.PRODUCT_LIST}>All Products</HeaderLink>
</HeaderSection>
<HeaderSection>
<HeaderLink to={routes.CART}>My Cart</HeaderLink>|
{isAuthenticated ? (
<>
<HeaderLink to={routes.ACCOUNT}>My Account</HeaderLink>|
<HeaderLink to={routes.LOGOUT}>Logout</HeaderLink>
</>
) : (
<>
<HeaderLink to={routes.LOGIN}>Log In</HeaderLink> |
<HeaderLink to={routes.SIGN_UP}>Sign Up</HeaderLink>
</>
)}
</HeaderSection>
</Header>
<Wrapper>{children}</Wrapper>
</Fragment>
)

const mapStateToProps = state => ({
isAuthenticated: Object.keys(state.customer).length !== 0,
})

const mapDispatchToProps = {
logout: customerActions.logout,
}

export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(Layout)
)
export default connect(mapStateToProps)(Layout)
8 changes: 5 additions & 3 deletions src/config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export default {
clientId: '1639340def6563ae1342dca16e5e9711e696ffe45f1c21fe4fba8e272a03f51a',
scope: 'market:335',
apiUrl: 'https://the-amber-brand-12.commercelayer.io',
clientId: '1add790e25ab4f3b0724b593a09e1d1008fcce751d4c2b2f337353051f439eda',
clientSecret:
'eac6464b9c4840222b1258732cb51c5db35ab472be4ba305b24813c8c0076839',
scope: 'market:710',
apiUrl: 'https://the-brown-brand-23.commercelayer.io',
}
1 change: 1 addition & 0 deletions src/globalStyles.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'sanitize.css'
import 'react-toastify/dist/ReactToastify.css'
import { createGlobalStyle } from 'styled-components'

import theme from './common/theme'
Expand Down
Loading

0 comments on commit af41308

Please sign in to comment.