From dbbcb4e5c1d6d8da954a8d8c844d4b40ebbf3cc8 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Wed, 17 Apr 2019 18:35:14 +0200 Subject: [PATCH 01/17] Install redux thunk --- package.json | 1 + src/store/index.js | 16 +++++++--------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 908c397..f2aff31 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "react-router-dom": "^5.0.0", "react-scripts": "2.1.8", "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/store/index.js b/src/store/index.js index 1b37dc8..659897b 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,5 +1,5 @@ -import { createStore, combineReducers } from 'redux' - +import { createStore, combineReducers, applyMiddleware, compose } from 'redux' +import thunk from 'redux-thunk' import cart from './cart/reducer' import customer from './customer/reducer' @@ -8,11 +8,9 @@ const reducer = combineReducers({ customer, }) +// this variable will be set if you have redux-dev-tools extension installed in your browser +// https://github.com/zalmoxisus/redux-devtools-extension#12-advanced-store-setup +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose + export const configureStore = (preloadedState = {}) => - createStore( - reducer, - preloadedState, - // this variable will be set if you have redux-dev-tools extension installed in your browser - // https://github.com/zalmoxisus/redux-devtools-extension#11-basic-store - window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() - ) + createStore(reducer, preloadedState, composeEnhancers(applyMiddleware(thunk))) From f35121d884b653ba054917a1523e674e53163d62 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Wed, 17 Apr 2019 19:44:28 +0200 Subject: [PATCH 02/17] Rewrite login action to thunk --- src/pages/LogIn/index.js | 12 ++++-------- src/store/customer/actions.js | 35 ++++++++++++++++++++++++++++++----- src/store/customer/reducer.js | 4 ++-- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/pages/LogIn/index.js b/src/pages/LogIn/index.js index 1179e9f..cd78299 100644 --- a/src/pages/LogIn/index.js +++ b/src/pages/LogIn/index.js @@ -8,10 +8,6 @@ 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 { schema } from './schema' const initialValues = { @@ -25,16 +21,16 @@ const LogInPage = ({ login, history }) => { const handleSubmit = async ({ email, password }, { setSubmitting }) => { try { setSubmitting(true) - const { ownerId } = await getCustomerToken({ + + await this.props.login({ username: email, password, + push: this.props.history.push, }) - const customer = await getCustomer(ownerId) - login(customer) - history.push(routes.ACCOUNT) } catch (error) { setGlobalError(error.message) } + setSubmitting(false) } diff --git a/src/store/customer/actions.js b/src/store/customer/actions.js index 77c2fc2..58b40c4 100644 --- a/src/store/customer/actions.js +++ b/src/store/customer/actions.js @@ -1,10 +1,35 @@ -export const LOGIN = 'customer/LOGIN' +import { getCustomerToken } from '../../api/customers/get-customer-token' +import { getCustomer } from '../../api/customers/get-customer' +import * as routes from '../../routes' + +export const LOGIN_INIT = 'customer/LOGIN_INIT' +export const LOGIN_FAIL = 'customer/LOGIN_FAIL' +export const LOGIN_SUCCESS = 'customer/LOGIN_SUCCESS' + export const LOGOUT = 'customer/LOGOUT' -export const login = customer => ({ - type: LOGIN, - payload: { customer }, -}) +export const login = ({ username, password, push }) => async dispatch => { + dispatch({ + type: LOGIN_INIT, + payload: { username, password }, + }) + + const { ownerId } = await getCustomerToken({ + username, + password, + }) + + const customer = await getCustomer(ownerId) + + dispatch({ + type: LOGIN_SUCCESS, + payload: { + customer, + }, + }) + + push(routes.ACCOUNT) +} export const logout = () => ({ type: LOGOUT, diff --git a/src/store/customer/reducer.js b/src/store/customer/reducer.js index 7f87ea4..7fe50c4 100644 --- a/src/store/customer/reducer.js +++ b/src/store/customer/reducer.js @@ -1,10 +1,10 @@ -import { LOGIN, LOGOUT } from './actions' +import { LOGOUT, LOGIN_SUCCESS } from './actions' const initialState = {} const reducer = (state = initialState, action) => { switch (action.type) { - case LOGIN: + case LOGIN_SUCCESS: return action.payload.customer case LOGOUT: From ba584b7d6b9f7ef0d4ae5495b681b3463cd12f7b Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Wed, 17 Apr 2019 21:34:42 +0200 Subject: [PATCH 03/17] Add WIP on refresh token api client integration --- src/api/api-client.js | 58 ++++++++++++++++----- src/api/customers/refresh-customer-token.js | 34 ++++++++++++ src/utils/refresh-token.js | 11 ++++ 3 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 src/api/customers/refresh-customer-token.js create mode 100644 src/utils/refresh-token.js diff --git a/src/api/api-client.js b/src/api/api-client.js index 49aa527..06346a0 100644 --- a/src/api/api-client.js +++ b/src/api/api-client.js @@ -1,22 +1,54 @@ +/* eslint-disable no-constant-condition */ +/* eslint-disable no-await-in-loop */ + import config from '../config' 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() + // Repeat until we dont return from function + while (true) { + let token = getToken() - if (!token) { - token = await getGuestToken() - } + // If we dont have a token request a quest token + if (!token) { + token = await getGuestToken() + } + + try { + // Do the request + const response = await fetch(`${config.apiUrl}${url}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/vnd.api+json', + Authorization: `Bearer ${token}`, + }, + ...options, + }) - const response = await fetch(`${config.apiUrl}${url}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/vnd.api+json', - Authorization: `Bearer ${token}`, - }, - ...options, - }) + // 401 unauthorized means we have expired token + if (response && response.status === 401) { + const refreshToken = getRefreshToken() - return response.json() + // If we have a refresh token this means we have logged in user + // and we need to refresh access token + if (refreshToken) { + await refreshCustomerToken() + // If no refresh token is present it means we had a expired quest token + // Guest tokens are acquired at the beignning of the request so let's + // just continue to the start of cycle + } else { + continue + } + } + + // If everything went fine just return the result + return response.json() + } catch (e) { + // TODO: redirect to login + console.log('redirecting') + } + } } diff --git a/src/api/customers/refresh-customer-token.js b/src/api/customers/refresh-customer-token.js new file mode 100644 index 0000000..b43be8b --- /dev/null +++ b/src/api/customers/refresh-customer-token.js @@ -0,0 +1,34 @@ +import { setRefreshToken, getRefreshToken } from '../../utils/refresh-token' +import { setToken } from '../../utils/token' + +import config from '../../config' + +export const refreshCustomerToken = async () => { + const refreshToken = getRefreshToken() + + const response = await fetch(`${config.apiUrl}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: config.clientId, + scope: config.scope, + }), + }) + + switch (response.status) { + case 200: { + const { refresh_token, access_token } = await response.json() + setToken(access_token) + setRefreshToken(refresh_token) + + return { accessToken: access_token, refreshToken: refresh_token } + } + default: + // TODO: should we throw error instead? + throw response + } +} diff --git a/src/utils/refresh-token.js b/src/utils/refresh-token.js new file mode 100644 index 0000000..3a91e7d --- /dev/null +++ b/src/utils/refresh-token.js @@ -0,0 +1,11 @@ +export const getRefreshToken = () => { + return window.localStorage.getItem('refreshtoken') +} + +export const setRefreshToken = Refreshtoken => { + window.localStorage.setItem('refreshtoken', Refreshtoken) +} + +export const removeRefreshToken = () => { + window.localStorage.removeItem('refreshtoken') +} From fe3cbfb9b6bd376200732133e00e191e835e2495 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Thu, 18 Apr 2019 15:18:10 +0200 Subject: [PATCH 04/17] Add WIP on refreshing token --- src/api/api-client.js | 16 ++++++++++++---- src/api/customers/get-customer-token.js | 4 +++- src/api/customers/refresh-customer-token.js | 5 +++-- src/components/Layout/index.js | 2 ++ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/api/api-client.js b/src/api/api-client.js index 06346a0..b265893 100644 --- a/src/api/api-client.js +++ b/src/api/api-client.js @@ -4,12 +4,12 @@ import config from '../config' import { getGuestToken } from './get-guest-token' import { refreshCustomerToken } from './customers/refresh-customer-token' -import { getToken } from '../utils/token' +import { getToken, removeToken } from '../utils/token' import { getRefreshToken } from '../utils/refresh-token' export const api = async (url, options) => { - // Repeat until we dont return from function - while (true) { + // Let's retry the request maximum of 2 times + for (let x = 0; x < 2; x++) { let token = getToken() // If we dont have a token request a quest token @@ -28,18 +28,26 @@ export const api = async (url, options) => { ...options, }) + console.log('Got response', response) + // 401 unauthorized means we have expired token if (response && response.status === 401) { const refreshToken = getRefreshToken() + console.log('refresh token', refreshToken) // If we have a refresh token this means we have logged in user // and we need to refresh access token if (refreshToken) { + console.log('awaiting refresh token') await refreshCustomerToken() + console.log('refresh token received') + // We got the token let's request again + continue // If no refresh token is present it means we had a expired quest token // Guest tokens are acquired at the beignning of the request so let's - // just continue to the start of cycle + // just remove token and continue to the start of cycle } else { + removeToken() continue } } diff --git a/src/api/customers/get-customer-token.js b/src/api/customers/get-customer-token.js index c103a89..8b9b90a 100644 --- a/src/api/customers/get-customer-token.js +++ b/src/api/customers/get-customer-token.js @@ -1,5 +1,6 @@ import config from '../../config' import { setToken } from '../../utils/token' +import { setRefreshToken } from '../../utils/refresh-token' export const getCustomerToken = async ({ username, password }) => { const response = await fetch(`${config.apiUrl}/oauth/token`, { @@ -18,8 +19,9 @@ export const getCustomerToken = async ({ username, password }) => { switch (response.status) { case 200: { - const { owner_id, access_token } = await response.json() + const { owner_id, access_token, refresh_token } = await response.json() setToken(access_token) + setRefreshToken(refresh_token) return { ownerId: owner_id, access_token } } diff --git a/src/api/customers/refresh-customer-token.js b/src/api/customers/refresh-customer-token.js index b43be8b..6806fc5 100644 --- a/src/api/customers/refresh-customer-token.js +++ b/src/api/customers/refresh-customer-token.js @@ -1,21 +1,22 @@ import { setRefreshToken, getRefreshToken } from '../../utils/refresh-token' -import { setToken } from '../../utils/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, - scope: config.scope, }), }) diff --git a/src/components/Layout/index.js b/src/components/Layout/index.js index f7b2d7b..b262dfc 100644 --- a/src/components/Layout/index.js +++ b/src/components/Layout/index.js @@ -6,6 +6,7 @@ import * as customerActions from '../../store/customer/actions' import * as routes from '../../routes' import { removeToken } from '../../utils/token' +import { removeRefreshToken } from '../../utils/refresh-token' import { removeCustomer } from '../../utils/customer' import { Wrapper, Header, HeaderSection, HeaderLink } from './styled' @@ -13,6 +14,7 @@ const Layout = ({ logout, isAuthenticated, history, children }) => { const handleLogout = () => { logout() removeToken() + removeRefreshToken() removeCustomer() history.push(routes.HOMEPAGE) } From 7a4cbdee876c23a89dc0477a7bdcfd3c43f5b8b1 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Thu, 18 Apr 2019 15:51:08 +0200 Subject: [PATCH 05/17] Change config --- src/api/customers/refresh-customer-token.js | 1 + src/config.js | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/api/customers/refresh-customer-token.js b/src/api/customers/refresh-customer-token.js index 6806fc5..5bf943d 100644 --- a/src/api/customers/refresh-customer-token.js +++ b/src/api/customers/refresh-customer-token.js @@ -17,6 +17,7 @@ export const refreshCustomerToken = async () => { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: config.clientId, + client_secret: config.clientSecret, }), }) 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', } From 775bbc8014155e52ad2209be3b8a78b8bff6c19e Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Sun, 21 Apr 2019 12:18:08 +0200 Subject: [PATCH 06/17] Finish api client --- src/api/api-client.js | 85 +++++++++------------ src/api/customers/refresh-customer-token.js | 2 +- 2 files changed, 38 insertions(+), 49 deletions(-) diff --git a/src/api/api-client.js b/src/api/api-client.js index b265893..14e6edf 100644 --- a/src/api/api-client.js +++ b/src/api/api-client.js @@ -4,59 +4,48 @@ import config from '../config' import { getGuestToken } from './get-guest-token' import { refreshCustomerToken } from './customers/refresh-customer-token' -import { getToken, removeToken } from '../utils/token' +import { getToken } from '../utils/token' import { getRefreshToken } from '../utils/refresh-token' -export const api = async (url, options) => { - // Let's retry the request maximum of 2 times - for (let x = 0; x < 2; x++) { - let token = getToken() - - // If we dont have a token request a quest token - if (!token) { - token = await getGuestToken() - } +const makeRequest = (url, options, token) => + fetch(`${config.apiUrl}${url}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/vnd.api+json', + Authorization: `Bearer ${token}`, + }, + ...options, + }) - try { - // Do the request - const response = await fetch(`${config.apiUrl}${url}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/vnd.api+json', - Authorization: `Bearer ${token}`, - }, - ...options, - }) - - console.log('Got response', response) - - // 401 unauthorized means we have expired token - if (response && response.status === 401) { - const refreshToken = getRefreshToken() - console.log('refresh token', refreshToken) - - // If we have a refresh token this means we have logged in user - // and we need to refresh access token - if (refreshToken) { - console.log('awaiting refresh token') - await refreshCustomerToken() - console.log('refresh token received') - // We got the token let's request again - continue - // If no refresh token is present it means we had a expired quest token - // Guest tokens are acquired at the beignning of the request so let's - // just remove token and continue to the start of cycle - } else { - removeToken() - continue - } +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() } - // If everything went fine just return the result - return response.json() - } catch (e) { - // TODO: redirect to login - console.log('redirecting') + // Repeat the request with the new token + response = await makeRequest(url, options, token) } + + // If everything went fine just return the result + return response.json() + } catch (e) { + // TODO: redirect to login + console.log('redirecting') } } diff --git a/src/api/customers/refresh-customer-token.js b/src/api/customers/refresh-customer-token.js index 5bf943d..19070c0 100644 --- a/src/api/customers/refresh-customer-token.js +++ b/src/api/customers/refresh-customer-token.js @@ -27,7 +27,7 @@ export const refreshCustomerToken = async () => { setToken(access_token) setRefreshToken(refresh_token) - return { accessToken: access_token, refreshToken: refresh_token } + return access_token } default: // TODO: should we throw error instead? From 7efa10c6684d339890e1997b7cd426e903d41b48 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Sun, 21 Apr 2019 23:31:20 +0200 Subject: [PATCH 07/17] Add sample error boundary --- package.json | 1 + src/App.js | 2 ++ src/components/ErrorBoundary/index.js | 16 ++++++++++++++++ src/pages/LogIn/index.js | 4 ++-- 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/components/ErrorBoundary/index.js diff --git a/package.json b/package.json index f2aff31..b446697 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "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", diff --git a/src/App.js b/src/App.js index 1cd9acb..34f638d 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 } from 'react-toastify' import GlobalStyles from './globalStyles' import { ProductList } from './pages/ProductList' @@ -23,6 +24,7 @@ const App = () => ( + { }) } catch (error) { setGlobalError(error.message) + } finally { + setSubmitting(false) } - - setSubmitting(false) } return ( From 19360ff0c0824bd35cc3e67072c2835f5936dd72 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Tue, 23 Apr 2019 00:07:26 +0200 Subject: [PATCH 08/17] Add sample error handles --- src/App.js | 31 +++++++++++---------- src/api/customers/get-customer-token.js | 3 +- src/api/customers/refresh-customer-token.js | 3 +- src/components/ErrorBoundary/index.js | 2 +- src/pages/LogIn/index.js | 16 +++++++---- src/utils/errors.js | 13 +++++++++ 6 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 src/utils/errors.js diff --git a/src/App.js b/src/App.js index 34f638d..99b3cc3 100644 --- a/src/App.js +++ b/src/App.js @@ -12,6 +12,7 @@ import { LogIn } from './pages/LogIn' 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' @@ -25,20 +26,22 @@ const App = () => ( - - } - /> - - - - - - - - + + + } + /> + + + + + + + + + ) diff --git a/src/api/customers/get-customer-token.js b/src/api/customers/get-customer-token.js index 8b9b90a..1428158 100644 --- a/src/api/customers/get-customer-token.js +++ b/src/api/customers/get-customer-token.js @@ -1,6 +1,7 @@ import config from '../../config' import { setToken } from '../../utils/token' import { setRefreshToken } from '../../utils/refresh-token' +import { AsyncValidationError } from '../../utils/errors' export const getCustomerToken = async ({ username, password }) => { const response = await fetch(`${config.apiUrl}/oauth/token`, { @@ -26,7 +27,7 @@ export const getCustomerToken = async ({ username, password }) => { return { ownerId: owner_id, access_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 index 19070c0..606d621 100644 --- a/src/api/customers/refresh-customer-token.js +++ b/src/api/customers/refresh-customer-token.js @@ -30,7 +30,6 @@ export const refreshCustomerToken = async () => { return access_token } default: - // TODO: should we throw error instead? - throw response + throw new Error('Cannot refresh customer token') } } diff --git a/src/components/ErrorBoundary/index.js b/src/components/ErrorBoundary/index.js index 64b89ea..e69162a 100644 --- a/src/components/ErrorBoundary/index.js +++ b/src/components/ErrorBoundary/index.js @@ -7,7 +7,7 @@ export class ErrorBoundary extends React.Component { position: toast.POSITION.TOP_CENTER, }) - console.log('Error boundary error', error, info) + console.error('Error boundary error', error, info) } render() { diff --git a/src/pages/LogIn/index.js b/src/pages/LogIn/index.js index cc9cbfc..7a91645 100644 --- a/src/pages/LogIn/index.js +++ b/src/pages/LogIn/index.js @@ -8,6 +8,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 { AsyncValidationError } from '../../utils/errors' import { schema } from './schema' const initialValues = { @@ -16,19 +17,22 @@ const initialValues = { } const LogInPage = ({ login, history }) => { - const [globalError, setGlobalError] = useState('') + const [formAsyncError, setFormAsyncError] = useState('') const handleSubmit = async ({ email, password }, { setSubmitting }) => { try { setSubmitting(true) - await this.props.login({ + await login({ username: email, password, - push: this.props.history.push, + push: history.push, }) } catch (error) { - setGlobalError(error.message) + if (error instanceof AsyncValidationError) { + setFormAsyncError(error.message) + } + // TODO: handle other errors } finally { setSubmitting(false) } @@ -44,8 +48,8 @@ const LogInPage = ({ login, history }) => { > {({ isSubmitting }) => (
- {Boolean(globalError) && ( - {globalError} + {Boolean(formAsyncError) && ( + {formAsyncError} )} diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..c642e2c --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,13 @@ +/* eslint-disable max-classes-per-file */ + +// Workaround because Babel cannot extend default Error +// https://stackoverflow.com/questions/31089801/extending-error-in-javascript-with-es6-syntax-babel +class CustomError { + constructor(message) { + this.name = 'CustomError' + this.message = message + } +} +CustomError.prototype = Object.create(Error.prototype) + +export class AsyncValidationError extends CustomError {} From 3c2d4a0b199c37e06c0de40c1445bfa63e76897e Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Tue, 23 Apr 2019 10:48:30 +0200 Subject: [PATCH 09/17] Rewrite logout to thunks --- src/components/Layout/index.js | 11 +++-------- src/store/customer/actions.js | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/Layout/index.js b/src/components/Layout/index.js index b262dfc..5e624b2 100644 --- a/src/components/Layout/index.js +++ b/src/components/Layout/index.js @@ -5,18 +5,13 @@ import { connect } from 'react-redux' import * as customerActions from '../../store/customer/actions' import * as routes from '../../routes' -import { removeToken } from '../../utils/token' -import { removeRefreshToken } from '../../utils/refresh-token' -import { removeCustomer } from '../../utils/customer' import { Wrapper, Header, HeaderSection, HeaderLink } from './styled' const Layout = ({ logout, isAuthenticated, history, children }) => { const handleLogout = () => { - logout() - removeToken() - removeRefreshToken() - removeCustomer() - history.push(routes.HOMEPAGE) + logout({ + push: history.push, + }) } return ( diff --git a/src/store/customer/actions.js b/src/store/customer/actions.js index 58b40c4..9d1208c 100644 --- a/src/store/customer/actions.js +++ b/src/store/customer/actions.js @@ -2,6 +2,10 @@ import { getCustomerToken } from '../../api/customers/get-customer-token' import { getCustomer } from '../../api/customers/get-customer' import * as routes from '../../routes' +import { removeToken } from '../../utils/token' +import { removeRefreshToken } from '../../utils/refresh-token' +import { removeCustomer } from '../../utils/customer' + export const LOGIN_INIT = 'customer/LOGIN_INIT' export const LOGIN_FAIL = 'customer/LOGIN_FAIL' export const LOGIN_SUCCESS = 'customer/LOGIN_SUCCESS' @@ -31,6 +35,14 @@ export const login = ({ username, password, push }) => async dispatch => { push(routes.ACCOUNT) } -export const logout = () => ({ - type: LOGOUT, -}) +export const logout = ({ push }) => dispatch => { + removeToken() + removeRefreshToken() + removeCustomer() + + push(routes.HOMEPAGE) + + dispatch({ + type: LOGOUT, + }) +} From 9f7b1df22df39555a6c8ccb65cbb1876349a1627 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Tue, 23 Apr 2019 11:42:03 +0200 Subject: [PATCH 10/17] Add logout functionality --- src/App.js | 7 +++++-- src/api/api-client.js | 11 +++++++++-- src/pages/LogIn/index.js | 7 ++++++- src/pages/Logout/index.js | 18 ++++++++++++++++++ src/routes.js | 1 + src/utils/refresh-token.js | 4 ++-- 6 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 src/pages/Logout/index.js diff --git a/src/App.js b/src/App.js index 99b3cc3..2fd1f91 100644 --- a/src/App.js +++ b/src/App.js @@ -1,7 +1,8 @@ import React from 'react' import { Switch, Route, Redirect } from 'react-router-dom' import { Provider } from 'react-redux' -import { ToastContainer } from 'react-toastify' +import { ToastContainer, toast } from 'react-toastify' +import 'react-toastify/dist/ReactToastify.css' import GlobalStyles from './globalStyles' import { ProductList } from './pages/ProductList' @@ -9,6 +10,7 @@ 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' @@ -25,7 +27,7 @@ const App = () => ( - + ( + diff --git a/src/api/api-client.js b/src/api/api-client.js index 14e6edf..4eb018f 100644 --- a/src/api/api-client.js +++ b/src/api/api-client.js @@ -2,6 +2,7 @@ /* 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' @@ -42,10 +43,16 @@ export const api = async (url, options) => { 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) { - // TODO: redirect to login - console.log('redirecting') + // Place to handle global api errors + throw e } } diff --git a/src/pages/LogIn/index.js b/src/pages/LogIn/index.js index 7a91645..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' @@ -31,8 +32,12 @@ const LogInPage = ({ login, history }) => { } catch (error) { 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 } - // TODO: handle other errors } finally { setSubmitting(false) } diff --git a/src/pages/Logout/index.js b/src/pages/Logout/index.js new file mode 100644 index 0000000..8ac2e06 --- /dev/null +++ b/src/pages/Logout/index.js @@ -0,0 +1,18 @@ +import React, { useEffect } from 'react' +import { connect } from 'react-redux' +import { logout } from '../../store/customer/actions' + +const LogoutPage = ({ logoutAction, history }) => { + useEffect(() => + logoutAction({ + push: history.push, + }) + ) + + return Logging out +} + +export const Logout = connect( + null, + { logoutAction: logout } +)(LogoutPage) diff --git a/src/routes.js b/src/routes.js index 04c3830..b20390a 100644 --- a/src/routes.js +++ b/src/routes.js @@ -9,3 +9,4 @@ export const CART = '/cart' export const SIGN_UP = '/signup' export const LOGIN = '/login' export const ACCOUNT = '/account' +export const LOGOUT = '/logout' diff --git a/src/utils/refresh-token.js b/src/utils/refresh-token.js index 3a91e7d..a798122 100644 --- a/src/utils/refresh-token.js +++ b/src/utils/refresh-token.js @@ -2,8 +2,8 @@ export const getRefreshToken = () => { return window.localStorage.getItem('refreshtoken') } -export const setRefreshToken = Refreshtoken => { - window.localStorage.setItem('refreshtoken', Refreshtoken) +export const setRefreshToken = refreshToken => { + window.localStorage.setItem('refreshtoken', refreshToken) } export const removeRefreshToken = () => { From a4b8ae706ca039b0fc70da300bdd8435134abeb7 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Tue, 23 Apr 2019 12:36:04 +0200 Subject: [PATCH 11/17] Fix Error Boundary --- src/components/ErrorBoundary/index.js | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/ErrorBoundary/index.js b/src/components/ErrorBoundary/index.js index e69162a..cb33acb 100644 --- a/src/components/ErrorBoundary/index.js +++ b/src/components/ErrorBoundary/index.js @@ -2,15 +2,29 @@ import React from 'react' import { toast } from 'react-toastify' export class ErrorBoundary extends React.Component { - componentDidCatch(error, info) { - toast.error(`Error: ${error.message}`, { - position: toast.POSITION.TOP_CENTER, - }) + 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.props.children + 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 + ) } } From aa703ee81fe75c17e702fd2fa5132c5532f7a588 Mon Sep 17 00:00:00 2001 From: Erik Majlath Date: Tue, 23 Apr 2019 17:35:56 +0200 Subject: [PATCH 12/17] Move global styles, change logout logic --- src/App.js | 1 - src/components/Layout/index.js | 71 ++++++++++++---------------------- src/globalStyles.js | 1 + 3 files changed, 26 insertions(+), 47 deletions(-) diff --git a/src/App.js b/src/App.js index 2fd1f91..c4299ae 100644 --- a/src/App.js +++ b/src/App.js @@ -2,7 +2,6 @@ import React from 'react' import { Switch, Route, Redirect } from 'react-router-dom' import { Provider } from 'react-redux' import { ToastContainer, toast } from 'react-toastify' -import 'react-toastify/dist/ReactToastify.css' import GlobalStyles from './globalStyles' import { ProductList } from './pages/ProductList' diff --git a/src/components/Layout/index.js b/src/components/Layout/index.js index 5e624b2..7708ae0 100644 --- a/src/components/Layout/index.js +++ b/src/components/Layout/index.js @@ -1,58 +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 { Wrapper, Header, HeaderSection, HeaderLink } from './styled' -const Layout = ({ logout, isAuthenticated, history, children }) => { - const handleLogout = () => { - logout({ - push: history.push, - }) - } - - 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/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' From 50eaf570eb199818da08e4ec1ce5c05d72197203 Mon Sep 17 00:00:00 2001 From: dannytce Date: Wed, 24 Apr 2019 08:49:01 +0200 Subject: [PATCH 13/17] Update yarn.lock --- yarn.lock | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/yarn.lock b/yarn.lock index 2145834..97c6bc7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2198,6 +2198,11 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +classnames@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" + integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== + clean-css@4.2.x: version "4.2.1" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17" @@ -2998,6 +3003,13 @@ dom-converter@^0.2: dependencies: utila "~0.4" +dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + dom-serializer@0: version "0.1.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" @@ -7796,6 +7808,11 @@ react-is@^16.8.2: version "16.8.6" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + react-redux@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-6.0.1.tgz#0d423e2c1cb10ada87293d47e7de7c329623ba4d" @@ -7887,6 +7904,26 @@ react-scripts@2.1.8: optionalDependencies: fsevents "1.2.4" +react-toastify@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-5.1.0.tgz#aefe1f084bf4733f083423f013d24242cb63c482" + integrity sha512-0kVAAE7VO609EeXLVaFHDTc6Bnd/OUAb7rrRAwMsHeaThKEhH+WEQEPftTjuA4rP59K0QhCnWu4Ds2hXAcFxaw== + dependencies: + "@babel/runtime" "^7.4.2" + classnames "^2.2.6" + prop-types "^15.7.2" + react-transition-group "^2.6.1" + +react-transition-group@^2.6.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + react@^16.8.4: version "16.8.4" resolved "https://registry.yarnpkg.com/react/-/react-16.8.4.tgz#fdf7bd9ae53f03a9c4cd1a371432c206be1c4768" @@ -7996,6 +8033,11 @@ redent@^2.0.0: indent-string "^3.0.0" strip-indent "^2.0.0" +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + redux@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.1.tgz#436cae6cc40fbe4727689d7c8fae44808f1bfef5" From 2db567ef387034d564053986924c637e46a1a8ae Mon Sep 17 00:00:00 2001 From: dannytce Date: Wed, 24 Apr 2019 11:38:45 +0200 Subject: [PATCH 14/17] Add README --- lectures/07-async-error-handling/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 lectures/07-async-error-handling/README.md diff --git a/lectures/07-async-error-handling/README.md b/lectures/07-async-error-handling/README.md new file mode 100644 index 0000000..1da57bb --- /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]() + +## 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) From 98a0b3dbc4a4f13f530f874afbeac8d34c6a2eb3 Mon Sep 17 00:00:00 2001 From: dannytce Date: Wed, 24 Apr 2019 11:48:33 +0200 Subject: [PATCH 15/17] Move tokens logic into login action --- src/api/customers/get-customer-token.js | 6 +----- src/store/customer/actions.js | 9 ++++++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/api/customers/get-customer-token.js b/src/api/customers/get-customer-token.js index 1428158..f7b047e 100644 --- a/src/api/customers/get-customer-token.js +++ b/src/api/customers/get-customer-token.js @@ -1,6 +1,4 @@ import config from '../../config' -import { setToken } from '../../utils/token' -import { setRefreshToken } from '../../utils/refresh-token' import { AsyncValidationError } from '../../utils/errors' export const getCustomerToken = async ({ username, password }) => { @@ -21,10 +19,8 @@ export const getCustomerToken = async ({ username, password }) => { switch (response.status) { case 200: { const { owner_id, access_token, refresh_token } = await response.json() - setToken(access_token) - setRefreshToken(refresh_token) - return { ownerId: owner_id, access_token } + return { ownerId: owner_id, access_token, refresh_token } } case 401: throw new AsyncValidationError('Email or password are incorrect') diff --git a/src/store/customer/actions.js b/src/store/customer/actions.js index 9d1208c..5c635de 100644 --- a/src/store/customer/actions.js +++ b/src/store/customer/actions.js @@ -2,8 +2,8 @@ import { getCustomerToken } from '../../api/customers/get-customer-token' import { getCustomer } from '../../api/customers/get-customer' import * as routes from '../../routes' -import { removeToken } from '../../utils/token' -import { removeRefreshToken } from '../../utils/refresh-token' +import { setToken, removeToken } from '../../utils/token' +import { setRefreshToken, removeRefreshToken } from '../../utils/refresh-token' import { removeCustomer } from '../../utils/customer' export const LOGIN_INIT = 'customer/LOGIN_INIT' @@ -18,11 +18,14 @@ export const login = ({ username, password, push }) => async dispatch => { payload: { username, password }, }) - const { ownerId } = await getCustomerToken({ + const { ownerId, access_token, refresh_token } = await getCustomerToken({ username, password, }) + setToken(access_token) + setRefreshToken(refresh_token) + const customer = await getCustomer(ownerId) dispatch({ From 78b4d3fa22c5c6c5250ce7793e7dd40849d85053 Mon Sep 17 00:00:00 2001 From: dannytce Date: Wed, 24 Apr 2019 12:04:36 +0200 Subject: [PATCH 16/17] SignUp logic --- src/api/customers/create-customer.js | 15 +-------------- src/pages/SignUp/index.js | 17 ++++++++++------- 2 files changed, 11 insertions(+), 21 deletions(-) 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/pages/SignUp/index.js b/src/pages/SignUp/index.js index 1e11b9e..c1cc804 100644 --- a/src/pages/SignUp/index.js +++ b/src/pages/SignUp/index.js @@ -8,10 +8,8 @@ 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 { createCustomer } from '../../api/customers/create-customer' -import { getCustomer } from '../../api/customers/get-customer' import { schema } from './schema' const initialValues = { @@ -24,13 +22,18 @@ const initialValues = { const SignUpPage = ({ login, history }) => { const [globalError, setGlobalError] = useState('') - const handleSubmit = async (values, { setSubmitting }) => { + const handleSubmit = async ( + { email, password, firstName }, + { setSubmitting } + ) => { try { setSubmitting(true) - const { ownerId } = await createCustomer(values) - const customer = await getCustomer(ownerId) - login(customer) - history.push(routes.ACCOUNT) + await createCustomer({ email, password, firstName }) + await login({ + username: email, + password, + push: history.push, + }) } catch (error) { setGlobalError(error.message) } From 2eb3605760786b378732b03e408d54b388d6fd51 Mon Sep 17 00:00:00 2001 From: dannytce Date: Wed, 24 Apr 2019 12:04:57 +0200 Subject: [PATCH 17/17] Add video link --- lectures/07-async-error-handling/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lectures/07-async-error-handling/README.md b/lectures/07-async-error-handling/README.md index 1da57bb..bbda122 100644 --- a/lectures/07-async-error-handling/README.md +++ b/lectures/07-async-error-handling/README.md @@ -1,7 +1,7 @@ # Authentication & Routing in depth - [Presentation](https://docs.google.com/presentation/d/1JCvCloP-MmZAFA6oY0tCUs6jVs_pIzUavwJMl5UYtPY/edit) -- [Video]() +- [Video](https://www.youtube.com/watch?v=V5URcw_KYFM) ## Homework