diff --git a/lectures/07-async-error-handling/README.md b/lectures/07-async-error-handling/README.md
new file mode 100644
index 0000000..bbda122
--- /dev/null
+++ b/lectures/07-async-error-handling/README.md
@@ -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)
diff --git a/package.json b/package.json
index 908c397..b446697 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.js b/src/App.js
index 1cd9acb..c4299ae 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,6 +1,7 @@
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'
@@ -8,9 +9,11 @@ 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'
@@ -23,20 +26,24 @@ const App = () => (
-
- }
- />
-
-
-
-
-
-
-
-
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
)
diff --git a/src/api/api-client.js b/src/api/api-client.js
index 49aa527..4eb018f 100644
--- a/src/api/api-client.js
+++ b/src/api/api-client.js
@@ -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',
@@ -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
+ }
}
diff --git a/src/api/customers/create-customer.js b/src/api/customers/create-customer.js
index 42f8d76..159c77b 100644
--- a/src/api/customers/create-customer.js
+++ b/src/api/customers/create-customer.js
@@ -1,5 +1,4 @@
import { api } from '../api-client'
-import { getCustomerToken } from './get-customer-token'
export const createCustomer = async ({ email, password, firstName }) => {
const requestBody = {
@@ -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')
diff --git a/src/api/customers/get-customer-token.js b/src/api/customers/get-customer-token.js
index c103a89..f7b047e 100644
--- a/src/api/customers/get-customer-token.js
+++ b/src/api/customers/get-customer-token.js
@@ -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`, {
@@ -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')
}
diff --git a/src/api/customers/refresh-customer-token.js b/src/api/customers/refresh-customer-token.js
new file mode 100644
index 0000000..606d621
--- /dev/null
+++ b/src/api/customers/refresh-customer-token.js
@@ -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')
+ }
+}
diff --git a/src/components/ErrorBoundary/index.js b/src/components/ErrorBoundary/index.js
new file mode 100644
index 0000000..cb33acb
--- /dev/null
+++ b/src/components/ErrorBoundary/index.js
@@ -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 ? (
+
+ We are sorry! There was an error which we were not able to recover from.
+ Please refresh the page
+
+ ) : (
+ this.props.children
+ )
+ }
+}
diff --git a/src/components/Layout/index.js b/src/components/Layout/index.js
index f7b2d7b..7708ae0 100644
--- a/src/components/Layout/index.js
+++ b/src/components/Layout/index.js
@@ -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 (
-
-
-
- All Products
-
-
- My Cart|
- {isAuthenticated ? (
- <>
- My Account|
-
- Logout
-
- >
- ) : (
- <>
- Log In |
- Sign Up
- >
- )}
-
-
- {children}
-
- )
-}
+const Layout = ({ isAuthenticated, children }) => (
+
+
+
+ All Products
+
+
+ My Cart|
+ {isAuthenticated ? (
+ <>
+ My Account|
+ Logout
+ >
+ ) : (
+ <>
+ Log In |
+ Sign Up
+ >
+ )}
+
+
+ {children}
+
+)
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)
diff --git a/src/config.js b/src/config.js
index 6dc65fe..7f3014f 100644
--- a/src/config.js
+++ b/src/config.js
@@ -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',
}
diff --git a/src/globalStyles.js b/src/globalStyles.js
index c5922bc..cecf064 100644
--- a/src/globalStyles.js
+++ b/src/globalStyles.js
@@ -1,4 +1,5 @@
import 'sanitize.css'
+import 'react-toastify/dist/ReactToastify.css'
import { createGlobalStyle } from 'styled-components'
import theme from './common/theme'
diff --git a/src/pages/LogIn/index.js b/src/pages/LogIn/index.js
index 1179e9f..cb0c863 100644
--- a/src/pages/LogIn/index.js
+++ b/src/pages/LogIn/index.js
@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { Formik } from 'formik'
import { connect } from 'react-redux'
+import { toast } from 'react-toastify'
import Layout from '../../components/Layout'
import { H1 } from '../../components/Typography'
@@ -8,10 +9,7 @@ import { Form, GlobalFormError } from '../../components/Form'
import { Input } from '../../components/Input'
import Button from '../../components/Button'
import * as customerActions from '../../store/customer/actions'
-import * as routes from '../../routes'
-
-import { getCustomerToken } from '../../api/customers/get-customer-token'
-import { getCustomer } from '../../api/customers/get-customer'
+import { AsyncValidationError } from '../../utils/errors'
import { schema } from './schema'
const initialValues = {
@@ -20,22 +18,29 @@ const initialValues = {
}
const LogInPage = ({ login, history }) => {
- const [globalError, setGlobalError] = useState('')
+ const [formAsyncError, setFormAsyncError] = useState('')
const handleSubmit = async ({ email, password }, { setSubmitting }) => {
try {
setSubmitting(true)
- const { ownerId } = await getCustomerToken({
+
+ await login({
username: email,
password,
+ push: history.push,
})
- const customer = await getCustomer(ownerId)
- login(customer)
- history.push(routes.ACCOUNT)
} catch (error) {
- setGlobalError(error.message)
+ if (error instanceof AsyncValidationError) {
+ setFormAsyncError(error.message)
+ } else {
+ toast.error(
+ `There was an error while logging in, please try again later!`
+ )
+ // This would be nice place to log errors to some external service
+ }
+ } finally {
+ setSubmitting(false)
}
- setSubmitting(false)
}
return (
@@ -48,8 +53,8 @@ const LogInPage = ({ login, history }) => {
>
{({ isSubmitting }) => (